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:
parent
99e6c37827
commit
43b95c84df
4 changed files with 84 additions and 4 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue