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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-08 21:30:49 +00:00
parent 99e6c37827
commit 43b95c84df
4 changed files with 84 additions and 4 deletions

View file

@ -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

View file

@ -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);

View file

@ -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;
}

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderMarkdown(text: string): string {
return escapeHtml(text)
// Bold: **text** or __text__
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
// Italic: *text* or _text_ (but not inside words with underscores)
.replace(/(?<!\w)\*([^*]+?)\*(?!\w)/g, '<em>$1</em>')
.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, '<em>$1</em>')
// Links: [text](url)
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
// Bare URLs
.replace(/(?<!["\w/])(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>')
// Line breaks
.replace(/\n/g, '<br>');
}
const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => {
const isUser = message.sender === 'user';
const isHuman = message.sender === 'human';
const html = useMemo(() => renderMarkdown(message.text), [message.text]);
return (
<motion.div
className={`chat-message chat-message--${message.sender}`}
@ -21,9 +47,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => {
{isHuman && message.agent && (
<span className="chat-message__agent">{message.agent}</span>
)}
<div className={`chat-message__bubble ${isUser ? 'chat-message__bubble--user' : isHuman ? 'chat-message__bubble--human' : 'chat-message__bubble--bot'}`}>
{message.text}
</div>
<div
className={`chat-message__bubble ${isUser ? 'chat-message__bubble--user' : isHuman ? 'chat-message__bubble--human' : 'chat-message__bubble--bot'}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
</motion.div>
);
};