Openclaw / customer_handler.py
Logan795's picture
Upload 14 files
d6f83fb verified
Raw History Blame Contribute Delete
17.4 kB
"""customer_handler.py β€” Full state machine for every lead.
States:
FOUND β†’ OUTREACH_SENT β†’ DETAILS_REQUESTED β†’ DEMO_BUILDING
β†’ DEMO_SENT β†’ NEGOTIATING β†’ PAYMENT_PENDING
β†’ PAYMENT_VERIFIED β†’ DELIVERED / REJECTED
"""
import asyncio
import logging
from typing import Optional
import ai_agent
import database as db
import email_manager
import website_builder
import netlify_deployer
import scraper as scraper_mod
from config import PAYPAL_EMAIL, BANK_INFO, SENDER_EMAILS
log = logging.getLogger(__name__)
_admin_notify_fn = None
def set_admin_notifier(fn):
global _admin_notify_fn
_admin_notify_fn = fn
async def _notify(text: str):
if _admin_notify_fn:
try:
await _admin_notify_fn(text)
except Exception as e:
log.error("Admin notify error: %s", e)
def _sender(lead) -> Optional[dict]:
sid = lead["sender_email_id"]
for a in SENDER_EMAILS:
if a["id"] == sid:
return a
return None
def _history(lead_id: int) -> str:
logs = db.get_email_history(lead_id)
if not logs:
return "No prior conversation."
parts = []
for e in logs[-6:]:
who = "Us" if e["direction"] == "outbound" else "Customer"
parts.append(f"[{who}]: {e['body'][:100]}...")
return "\n".join(parts)
async def _reply_to(lead, reply: dict, body: str, subject_prefix: str = "Re: "):
subj = subject_prefix + reply["subject"]
sender = _sender(lead)
used = await email_manager.send_email(reply["from_email"], subj, body, sender=sender)
if used:
db.log_email(lead["id"], used, "outbound", subj, body)
# ── Build and deploy demo site ────────────────────────────────────────────
async def _build_and_deploy(lead_id: int, extra_details: dict = None) -> tuple:
"""Returns (site_id, live_url, zip_path)."""
lead = db.get_lead(lead_id)
extra = extra_details or {}
db.update_lead(lead_id, state="DEMO_BUILDING")
# Scrape extra business details from Google Maps if not provided
if not extra:
log.info("πŸ” Fetching business details for '%s'...", lead["business_name"])
try:
extra = await scraper_mod.fetch_business_details_for_lead(lead_id)
except Exception as e:
log.warning("Could not fetch extra details: %s", e)
# DeepSeek R1 generates content
content = ai_agent.generate_site_content(
business_name = lead["business_name"],
category = lead["category"] or "business",
address = lead["address"] or "",
phone = lead["phone"] or "",
email = lead["email"],
business_hours = extra.get("hours",""),
description = extra.get("description",""),
social_links = [],
)
# Build HTML
site_dir = website_builder.build_site_from_content(lead_id, content)
zip_path = website_builder.get_site_zip_path(site_dir)
# Deploy to Netlify
site_id, live_url = netlify_deployer.deploy_for_lead(
lead_id, lead["business_name"], zip_path
)
# Store zip path for possible re-deploy after payment
db.update_lead(lead_id,
state="DEMO_SENT",
netlify_site_id=site_id,
netlify_url=live_url,
notes=zip_path) # reuse notes field for zip path
return site_id, live_url, zip_path
# ── Main reply handler ────────────────────────────────────────────────────
async def handle_reply(reply: dict):
lead_id = reply["lead_id"]
body = reply["body"]
from_email = reply["from_email"]
lead = db.get_lead(lead_id)
if not lead:
return
# Log inbound
db.log_email(lead_id, from_email, "inbound",
reply["subject"], body, reply.get("message_id",""))
intent = ai_agent.classify_reply(body)
log.info("🧠 Lead #%d '%s' β†’ %s", lead_id, lead["business_name"], intent)
history = _history(lead_id)
# ── NOT INTERESTED ────────────────────────────────────────────────────
if intent == "NEGATIVE":
resp = ai_agent.compose_reply("NEGATIVE", lead["business_name"], history_summary=history)
await _reply_to(lead, reply, resp)
db.update_lead(lead_id, state="REJECTED")
return
# ── POSITIVE β€” ask for business details before building ───────────────
if intent == "POSITIVE":
ask_body = ai_agent.generate_details_request_email(lead["business_name"])
await _reply_to(lead, reply, ask_body)
db.update_lead(lead_id, state="DETAILS_REQUESTED")
await _notify(
f"✨ *Positive Reply!*\n"
f"Business: *{lead['business_name']}*\n"
f"Email: {from_email}\n"
"Asked them for business details (photos, hours, description)."
)
return
# ── CUSTOMER SENDS DETAILS β€” build + deploy demo ──────────────────────
if intent == "DETAILS" or (
lead["state"] == "DETAILS_REQUESTED" and intent == "NEUTRAL"
):
await _notify(
f"πŸ“‹ *Details received from {lead['business_name']}*\n"
"Building their demo website now..."
)
try:
_, live_url, _ = await _build_and_deploy(lead_id)
except Exception as e:
log.error("Build error for lead #%d: %s", lead_id, e)
await _notify(f"⚠️ Build failed for lead #{lead_id}: {e}")
return
resp = (
f"Hi {lead['business_name']} team! πŸŽ‰\n\n"
f"Your demo website is ready! Check it out here:\n\n"
f"πŸ‘‰ {live_url}\n\n"
"Let me know what you think! If you'd like any changes to colours, "
"text, layout β€” just reply and I'll update it right away.\n\n"
"If you're happy with it, I'll give you the final price to make it permanently yours!\n\n"
"Best,\nWebReach Team"
)
await _reply_to(lead, reply, resp)
await _notify(
f"πŸš€ *Demo Deployed!*\n"
f"Business: *{lead['business_name']}*\n"
f"URL: {live_url}"
)
return
# ── EDIT REQUEST ──────────────────────────────────────────────────────
if intent == "EDIT_REQUEST":
edits = ai_agent.extract_edit_instructions(body)
db.update_lead(lead_id, state="DEMO_SENT",
notes=(db.get_lead(lead_id)["notes"] or "") + f" | EDITS: {edits}")
# Rebuild with same details but note the edits
try:
_, live_url, _ = await _build_and_deploy(lead_id)
except Exception as e:
log.error("Rebuild error: %s", e)
return
resp = ai_agent.compose_reply(
"EDIT_REQUEST", lead["business_name"],
netlify_url=live_url, extra=edits, history_summary=history
)
await _reply_to(lead, reply, resp)
return
# ── SATISFIED β€” quote price ───────────────────────────────────────────
if intent == "SATISFIED":
price = ai_agent.calculate_price(
lead["business_name"], lead["category"] or "", history
)
db.update_lead(lead_id, state="NEGOTIATING", price_quoted=price)
resp = ai_agent.compose_reply(
"SATISFIED", lead["business_name"],
netlify_url=lead["netlify_url"] or "",
price=price, history_summary=history
)
await _reply_to(lead, reply, resp)
return
# ── PRICING QUESTION ──────────────────────────────────────────────────
if intent == "PRICING":
price = ai_agent.calculate_price(
lead["business_name"], lead["category"] or "", history
)
db.update_lead(lead_id, state="NEGOTIATING", price_quoted=price)
resp = ai_agent.compose_reply(
"PRICING", lead["business_name"], price=price, history_summary=history
)
await _reply_to(lead, reply, resp)
return
# ── CUSTOMER CLAIMS PAID ──────────────────────────────────────────────
if intent == "PAID":
db.update_lead(lead_id, state="PAYMENT_PENDING")
db.add_payment_request(lead_id, lead["price_quoted"] or 0)
# Deploy the real site immediately (but it's not "approved" yet)
live_url = lead["netlify_url"] or ""
if not live_url:
try:
_, live_url, _ = await _build_and_deploy(lead_id)
except Exception as e:
log.error("Deploy error: %s", e)
# Ask about domain (explain what it is simply)
domain_ask = (
f"Hi {lead['business_name']} team!\n\n"
"Thank you β€” we're verifying your payment now and will confirm shortly. βœ…\n\n"
"Quick question: do you have a **domain name** for your website?\n\n"
"A domain is your website address β€” like *mybusiness.com* β€” "
"so people can find you easily instead of a long link.\n\n"
"If you have one, just reply with it and we'll connect it for you.\n"
"If you don't have one yet, no worries β€” your site will work on our link for now, "
"and we can help you get a domain later!\n\n"
"Best,\nWebReach Team"
)
await _reply_to(lead, reply, domain_ask)
# Alert admin on Telegram
await _notify(
f"πŸ’° *PAYMENT CLAIM*\n"
f"Lead #: `{lead_id}`\n"
f"Business: *{lead['business_name']}*\n"
f"Customer: {from_email}\n"
f"Amount: ${lead['price_quoted'] or '?':.0f}\n"
f"Site: {live_url}\n\n"
f"βœ… /verify {lead_id} β€” payment confirmed\n"
f"❌ /reject {lead_id} β€” not paid, block site"
)
return
# ── DOMAIN PROVIDED ───────────────────────────────────────────────────
if intent == "OWN_DOMAIN_PROVIDED":
domain = ai_agent.extract_domain_from_reply(body)
if domain:
db.update_lead(lead_id, custom_domain=domain)
resp = ai_agent.compose_reply(
"OWN_DOMAIN_PROVIDED", lead["business_name"],
extra=domain or "domain not clear", history_summary=history
)
await _reply_to(lead, reply, resp)
return
# ── ASKING ABOUT DOMAIN ───────────────────────────────────────────────
if intent == "DOMAIN":
resp = ai_agent.compose_reply("DOMAIN", lead["business_name"], history_summary=history)
await _reply_to(lead, reply, resp)
return
# ── ASKING FOR SOURCE CODES ───────────────────────────────────────────
if intent == "CODES":
if lead["payment_confirmed"]:
# Payment already verified β€” send codes notice
resp = (
f"Hi {lead['business_name']} team,\n\n"
"Great news β€” since your payment is verified, we'll send over your website files shortly!\n\n"
"We'll email them to you as a ZIP file within 24 hours.\n\n"
"Best,\nWebReach Team"
)
await _notify(
f"πŸ“¦ *Code Request β€” APPROVED*\n"
f"Lead #{lead_id}: *{lead['business_name']}*\n"
"Payment already verified. Please send them the ZIP."
)
else:
# Not yet verified β€” hold
resp = ai_agent.compose_reply("CODES", lead["business_name"], history_summary=history)
await _notify(
f"⚠️ *Code Request β€” NOT VERIFIED*\n"
f"Lead #{lead_id}: *{lead['business_name']}*\n"
"Payment NOT confirmed. Holding codes."
)
await _reply_to(lead, reply, resp)
return
# ── OTHER HOST ────────────────────────────────────────────────────────
if intent == "OTHER_HOST":
resp = ai_agent.compose_reply("OTHER_HOST", lead["business_name"], history_summary=history)
await _reply_to(lead, reply, resp)
db.update_lead(lead_id, notes="Wants own hosting")
return
# ── NEUTRAL / FALLBACK ────────────────────────────────────────────────
resp = ai_agent.compose_reply("NEUTRAL", lead["business_name"], history_summary=history)
await _reply_to(lead, reply, resp)
# ── Admin: verify payment ─────────────────────────────────────────────────
async def verify_payment(lead_id: int):
"""Admin confirmed payment β€” unblock/redeploy and deliver."""
lead = db.get_lead(lead_id)
if not lead:
return
db.confirm_payment(lead_id)
live_url = lead["netlify_url"] or ""
# Attach custom domain if provided
if lead["custom_domain"] and lead["netlify_site_id"]:
try:
live_url = netlify_deployer.set_custom_domain(
lead["netlify_site_id"], lead["custom_domain"]
)
db.update_lead(lead_id, netlify_url=live_url)
except Exception as e:
log.warning("Domain attach error: %s", e)
# Email customer with live URL
sender = _sender(lead)
delivery_body = (
f"Hi {lead['business_name']} team! πŸŽ‰\n\n"
"Your payment has been confirmed β€” your website is now fully LIVE!\n\n"
f"πŸ‘‰ {live_url}\n\n"
+ (f"Your custom domain *{lead['custom_domain']}* has been connected. "
"DNS changes can take up to 24 hours to fully propagate worldwide.\n\n"
if lead["custom_domain"] else "")
+ "Your website will stay live. If you'd like any future changes, just reach out!\n\n"
"Thank you for choosing us β€” we'd love a review when you get a chance! 😊\n\n"
"Best,\nWebReach Team"
)
used = await email_manager.send_email(lead["email"], "πŸŽ‰ Your website is LIVE!", delivery_body, sender=sender)
if used:
db.log_email(lead_id, used, "outbound", "πŸŽ‰ Your website is LIVE!", delivery_body)
db.update_lead(lead_id, state="DELIVERED")
log.info("πŸŽ‰ Delivered to lead #%d", lead_id)
await _notify(
f"βœ… *DELIVERED!*\n"
f"Business: *{lead['business_name']}*\n"
f"URL: {live_url}"
)
# ── Admin: reject payment β€” block site ───────────────────────────────────
async def reject_payment(lead_id: int):
"""Admin said not paid β€” block the Netlify site."""
lead = db.get_lead(lead_id)
if not lead:
return
if lead["netlify_site_id"]:
try:
netlify_deployer.block_site(lead["netlify_site_id"])
log.info("πŸ”’ Site blocked for lead #%d", lead_id)
except Exception as e:
log.error("Block site error: %s", e)
db.update_lead(lead_id, state="NEGOTIATING", notes="Payment rejected β€” site blocked")
# Tell customer politely
sender = _sender(lead)
msg = (
f"Hi {lead['business_name']} team,\n\n"
"We weren't able to verify your payment yet. "
"The demo has been temporarily paused.\n\n"
"Once payment is confirmed, we'll restore it immediately. "
f"Please send payment to:\n"
f"πŸ’³ PayPal: {PAYPAL_EMAIL}\n"
f"🏦 {BANK_INFO}\n\n"
"Reply once you've sent it and we'll reactivate your site right away!\n\n"
"Best,\nWebReach Team"
)
used = await email_manager.send_email(lead["email"], "Payment Verification Needed", msg, sender=sender)
if used:
db.log_email(lead_id, used, "outbound", "Payment Verification Needed", msg)
await _notify(f"πŸ”’ Site blocked for lead #{lead_id}: *{lead['business_name']}*")
# ── Poll all replies ──────────────────────────────────────────────────────
async def process_all_replies():
replies = await email_manager.check_all_replies()
if replies:
log.info("πŸ“¬ Processing %d replies", len(replies))
for r in replies:
await handle_reply(r)
else:
log.debug("πŸ“­ No new replies")