From 43b95c84dfc263b4a60fcb3d285e68590c640c39 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Sun, 8 Mar 2026 21:30:49 +0000 Subject: [PATCH] Add markdown rendering in chat widget + opportunities for new leads - Chat messages now render bold, italic, links, and line breaks - Bare URLs auto-linked, XSS-safe via HTML escaping before markdown - New leads create Opportunity with stage NEW in Twenty CRM - Applied to both chatbot-api and email-api (contact + quote forms) Co-Authored-By: Claude Opus 4.6 --- chatbot-api/twenty_crm.py | 32 +++++++++++++++++++++++++++++++ email-api/index.mjs | 7 +++++++ src/components/ChatMessage.css | 14 ++++++++++++++ src/components/ChatMessage.tsx | 35 ++++++++++++++++++++++++++++++---- 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/chatbot-api/twenty_crm.py b/chatbot-api/twenty_crm.py index 4cc3852..3b1edfb 100644 --- a/chatbot-api/twenty_crm.py +++ b/chatbot-api/twenty_crm.py @@ -178,6 +178,33 @@ async def create_note(title: str, person_id: str | None = None) -> str | None: return None +async def create_opportunity(name: str, company_id: str | None = None, point_of_contact_id: str | None = None) -> str | None: + """Create an opportunity in NEW stage.""" + if not settings.twenty_crm_api_key: + return None + try: + body: dict = {"name": name, "stage": "NEW"} + if company_id: + body["companyId"] = company_id + if point_of_contact_id: + body["pointOfContactId"] = point_of_contact_id + async with httpx.AsyncClient() as http: + resp = await http.post( + f"{BASE_URL}/opportunities", + headers=HEADERS, + json=body, + timeout=10, + ) + if resp.status_code == 201: + opp_id = resp.json()["data"]["createOpportunity"]["id"] + logger.info(f"Twenty opportunity created: {opp_id} ({name})") + return opp_id + logger.error(f"Twenty create_opportunity: {resp.status_code} {resp.text}") + except Exception as e: + logger.error(f"Twenty create_opportunity error: {e}") + return None + + async def create_lead_in_crm( name: str, email: str, @@ -228,6 +255,11 @@ async def create_lead_in_crm( f"Chatbot lead: {need} (page: {page_context})", person_id ) + # Create opportunity in NEW stage + if person_id: + opp_name = f"{name} — {need[:50]}" if need else name + await create_opportunity(opp_name, company_id=company_id, point_of_contact_id=person_id) + return person_id diff --git a/email-api/index.mjs b/email-api/index.mjs index 099358d..bff6b05 100644 --- a/email-api/index.mjs +++ b/email-api/index.mjs @@ -62,6 +62,13 @@ async function createLeadInCRM({ fullName, workEmail, companyName, jobTitle, pho } } console.log(`CRM lead created: ${personId} (${fullName})`); + // Create opportunity in NEW stage + const oppBody = { name: `${fullName} — ${(need || 'Contact form').substring(0, 50)}`, stage: 'NEW' }; + if (companyId) oppBody.companyId = companyId; + if (personId) oppBody.pointOfContactId = personId; + await fetch(`${TWENTY_CRM_URL}/rest/opportunities`, { + method: 'POST', headers, body: JSON.stringify(oppBody), + }); } } catch (err) { console.error('CRM error:', err.message); diff --git a/src/components/ChatMessage.css b/src/components/ChatMessage.css index d15eae0..f221c05 100644 --- a/src/components/ChatMessage.css +++ b/src/components/ChatMessage.css @@ -50,3 +50,17 @@ border-bottom-left-radius: 4px; border-left: 2px solid var(--yellow-100); } + +.chat-message__bubble a { + color: var(--yellow-100); + text-decoration: underline; + text-underline-offset: 2px; +} + +.chat-message__bubble--user a { + color: #fff; +} + +.chat-message__bubble a:hover { + opacity: 0.85; +} diff --git a/src/components/ChatMessage.tsx b/src/components/ChatMessage.tsx index 1bea2a2..607dbb4 100644 --- a/src/components/ChatMessage.tsx +++ b/src/components/ChatMessage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { motion } from 'framer-motion'; import type { ChatMessage as ChatMessageType } from '../types/chat'; import './ChatMessage.css'; @@ -7,10 +7,36 @@ interface ChatMessageProps { message: ChatMessageType; } +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderMarkdown(text: string): string { + return escapeHtml(text) + // Bold: **text** or __text__ + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + // Italic: *text* or _text_ (but not inside words with underscores) + .replace(/(?$1') + .replace(/(?$1') + // Links: [text](url) + .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1') + // Bare URLs + .replace(/(?$1') + // Line breaks + .replace(/\n/g, '
'); +} + const ChatMessage: React.FC = ({ message }) => { const isUser = message.sender === 'user'; const isHuman = message.sender === 'human'; + const html = useMemo(() => renderMarkdown(message.text), [message.text]); + return ( = ({ message }) => { {isHuman && message.agent && ( {message.agent} )} -
- {message.text} -
+
); };