apps/web/src/lib/handoffContact.ts
1import type { ChatMessage, UserProfile } from '@local/ai'
2import { normalizeEmailEscapes } from '@local/ai/components/workflows'
3import { HANDOFF_CONSENT } from '@local/config/ai-consent'
4import { getBusinessEmailValidationMessage } from '@local/forms/business-email'
5
6/** Flag an explicitly supplied malformed email before asking for another field. */
7export function malformedContactEmail(message: string): string | undefined {
8 const text = normalizeEmailEscapes(message)
9 const candidate =
10 text.match(/\S+@\S+/)?.[0]?.replace(/[.!;]$/, '') ||
11 text.match(
12 /\bemail(?: it)?(?: address)? (?:to|is|:)\s+([\w.-]+\.[\w.-]+)/i,
13 )?.[1]
14 if (candidate && !/^[^\s@]+@[^\s@]+\.[^\s@,]+$/.test(candidate))
15 return 'That email address is not in a valid format. Please provide the full address with an @ and domain, for example name@example.com.'
16 return undefined
17}
18
19/** Shared requirements for ticket, sponsorship and agenda handoffs. */
20export function missingContactQuestion(
21 profile: UserProfile | undefined,
22 _agenda = false,
23): string | undefined {
24 if (!profile?.company?.trim()) return 'What company are you with?'
25 if (!profile.role?.trim()) return 'What is your job role?'
26 if (!profile.name?.trim()) return 'What name should I use?'
27 if (!profile.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profile.email))
28 return 'What work email address should I use?'
29 return getBusinessEmailValidationMessage(profile.email) || undefined
30}
31
32/** Tool arguments must come from visitor statements or an already saved profile. */
33export function groundedContact(
34 profile: UserProfile | undefined,
35 proposed: UserProfile,
36 history: ChatMessage[],
37): UserProfile {
38 const contact: UserProfile = { ...profile }
39 for (const field of ['name', 'email', 'company', 'role'] as const) {
40 const value = proposed[field]?.trim()
41 if (!value) continue
42 const evidenced = history.some((turn) => {
43 if (turn.role !== 'user') return false
44 const text = normalizeEmailEscapes(turn.text)
45 // An email local part/domain does not establish a name or company.
46 const source = field === 'email' ? text : text.replace(/\S+@\S+/g, '')
47 return source.toLowerCase().includes(value.toLowerCase())
48 })
49 if (evidenced) contact[field] = value
50 }
51 return contact
52}
53
54export function agendaContactReply(
55 profile: UserProfile | undefined,
56 consentStatement: string = HANDOFF_CONSENT.agenda,
57): string {
58 return (
59 missingContactQuestion(profile, true) ||
60 `I’ll email your selected agenda to ${profile?.name}, ${profile?.email} (${profile?.role}, ${profile?.company}).\n\n${consentStatement}`
61 )
62}
63
64/** A short answer to a single contact-field question is explicit visitor data. */
65export function contactAnswer(
66 profile: UserProfile,
67 question: string,
68 message: string,
69): UserProfile {
70 const value = message.trim()
71 if (
72 !value ||
73 value.length > 200 ||
74 /[?@\n]/.test(value) ||
75 /^(?:yes|no|no thanks|not now|cancel|skip|thanks|sure|i don.t know|prefer not|why|what|how|can |could )/i.test(
76 value,
77 )
78 )
79 return profile
80 const field = /^What company are you with(?: for [^?]+)?\?$/i.test(
81 question.trim(),
82 )
83 ? 'company'
84 : /^What is your job role(?: for [^?]+)?\?$/i.test(question.trim())
85 ? 'role'
86 : /^What name should I use(?: for [^?]+)?\?$/i.test(question.trim())
87 ? 'name'
88 : undefined
89 if (!field) return profile
90 // Longer sentences are left to extraction; this path handles literal answers like UNLEASH.
91 if (
92 value.split(/\s+/).length > 8 ||
93 /\b(?:i am|i work|my name|my (?:job )?role|my company|actually|instead)\b/i.test(
94 value,
95 )
96 )
97 return profile
98 return { ...profile, [field]: value }
99}
100
101/** A final confirmation must refer to the current recipient, including corrections. */
102export function hasContactSummary(
103 text: string,
104 profile: UserProfile | undefined,
105): boolean {
106 const normalized = normalizeEmailEscapes(text).toLowerCase()
107 return (['name', 'email', 'company', 'role'] as const).every((field) => {
108 const value = profile?.[field]?.trim().toLowerCase()
109 return Boolean(value && normalized.includes(value))
110 })
111}
112