Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| LD Events + Lamaki Designs β Intelligent Secretary Bot | |
| Fully merged catalogue + About Us + booking flow + in-memory persistence. | |
| Parts: 1/4 | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import re | |
| import uuid | |
| import datetime | |
| import logging | |
| from typing import Dict, Optional, List, Any | |
| from flask import Flask, request, jsonify | |
| # ---------- CONFIG ---------- | |
| VERIFY_TOKEN = os.getenv("WEBHOOK_VERIFY", "ldlamaki2025") | |
| PORT = int(os.getenv("PORT", 7860)) | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s") | |
| log = logging.getLogger("ldbot") | |
| # ---------- IN-MEMORY STORES ---------- | |
| CHAT_HISTORY: Dict[str, List[Dict[str, str]]] = {} | |
| DRAFT_BOOKINGS: Dict[str, Dict[str, Any]] = {} | |
| CONFIRMED_BOOKINGS: Dict[str, Dict[str, Any]] = {} | |
| CALL_REQUESTS: Dict[str, bool] = {} | |
| LAST_ITEM: Dict[str, str] = {} | |
| def save_msg(user: str, text: str, role: str = "assistant"): | |
| """Save last messages per user in-memory for lightweight conversation continuity.""" | |
| hist = CHAT_HISTORY.setdefault(user, []) | |
| hist.append({"role": role, "text": text, "time": datetime.datetime.utcnow().isoformat()}) | |
| if len(hist) > 60: | |
| hist.pop(0) | |
| # ---------- Normalization & small helpers ---------- | |
| _rx_clean = re.compile(r"[^a-z0-9\s\-\:\/\+]") | |
| def normalize(text: str) -> str: | |
| if text is None: | |
| return "" | |
| t = text.lower() | |
| return _rx_clean.sub("", t).strip() | |
| def gen_booking_code() -> str: | |
| return uuid.uuid4().hex[:8].upper() | |
| def parse_int_from_text(text: str) -> Optional[int]: | |
| if not text: | |
| return None | |
| m = re.search(r"(\d{1,5})", text) | |
| if m: | |
| try: | |
| return int(m.group(1)) | |
| except Exception: | |
| return None | |
| return None | |
| def find_date_in_text(text: str) -> Optional[str]: | |
| if not text: | |
| return None | |
| # ISO-ish or common formats | |
| m = re.search(r"(20\d{2}[-/]\d{1,2}[-/]\d{1,2})", text) | |
| if m: | |
| return m.group(1) | |
| m = re.search(r"(\d{1,2}[-/]\d{1,2}[-/](?:20)?\d{2})", text) | |
| if m: | |
| return m.group(1) | |
| return None | |
| def friendly(msg: str) -> str: | |
| return msg.strip() | |
| # ---------- Small utility for safe dict get --> | |
| def get_last_user_messages(user: str, n: int = 5) -> List[str]: | |
| hist = CHAT_HISTORY.get(user, []) | |
| return [h["text"] for h in hist[-n:]] | |
| # PART 2/4 | |
| """ | |
| Knowledge base: catalogue items, packages, combos, About Us, testimonials, contact info. | |
| """ | |
| # ---------- Knowledge base mapping ---------- | |
| REPLY: Dict[str, str] = {} | |
| def add(k: str, v: str): | |
| REPLY[normalize(k)] = v | |
| # --- AUDIO / SOUND ITEMS & PRICES (merged from images + PDF) --- | |
| add("double 18", "Double 18\" sub β KES 6,000 per unit (day).") | |
| add("single 18", "Single 18\" sub β KES 3,000 per unit (day).") | |
| add("line array", "Line-array top β KES 4,000 per unit (day).") | |
| add("monitors", "Monitor speaker β KES 2,000 per unit (day).") | |
| add("amplifier 4ch", "Amplifier 4ch β KES 4,000 per unit (day).") | |
| add("mixer wing", "Mixer Wing + stage box β KES 20,000 (event).") | |
| add("mixer skytone", "Mixer (Skytone) β KES 8,000 (event).") | |
| add("snake cable", "Snake cable β KES 5,000 (event).") | |
| add("cordless mic", "Cordless mic β KES 2,000 (per mic).") | |
| add("corded mic", "Corded mic β KES 500 (per mic).") | |
| add("drone 2h", "Drone coverage (2 hours) β KES 10,000. (Included in Platinum package)") | |
| # --- LIGHTS & VISUALS --- | |
| add("parcan lights", "Parcan lights β KES 1,500 per unit/day.") | |
| add("moving head", "Moving head β KES 3,000 per unit/day.") | |
| add("bee eye", "BEE eye β KES 2,000 per unit/day.") | |
| add("wall wash", "Wall wash β KES 2,000 per unit/day.") | |
| add("led screen panel", "LED screen panel β KES 2,200 per panel/day.") | |
| add("tv screen", "TV screen β KES 6,000 (event).") | |
| # --- TENTS & FURNITURE --- | |
| add("a frame tent", "A-frame tent β KES 30,000 (fits ~100 guests).") | |
| add("dome tent", "Dome tent β KES 45,000 (fits ~200 guests).") | |
| add("clear span tent", "Clear-span tent β KES 80,000 (large events up to ~500 guests).") | |
| add("chair", "Chair β KES 20 each.") | |
| add("tables", "Table β KES 500 each.") | |
| # --- PACKAGES (merged) --- | |
| add("bronze package", ( | |
| "Bronze Package (KES ~150kβ200k): 4 speakers with stands, mixer + amp, 1 cordless mic, basic lights, A-frame tent or simple setup β best for smaller ceremonies." | |
| )) | |
| add("silver package", ( | |
| "Silver Package (KES ~350kβ450k): 3 line-array tops, 2 single 18\" subs, digital mixer, 2 amplifiers, monitor speakers, dome tent recommended β great for medium events." | |
| )) | |
| add("gold package", ( | |
| "Gold Package (KES ~650kβ850k): 4 line-array tops, 4 double 18\" subs, LED wall or panels, amplifier rack, monitor speakers, full rig for large weddings & graduations." | |
| )) | |
| add("platinum package", ( | |
| "Platinum Package (KES 1.2Mβ1.8M): Full concert rig + live streaming + drone + large clear-span tent. For big concerts & stadium events." | |
| )) | |
| # --- COMBOS --- | |
| add("4 tops 2 subs combo", "4 line arrays + 2 double-18 subs + mixer + monitors + mics β KES 22,000 per day.") | |
| add("6 tops 4 subs combo", "6 line arrays + 4 double-18 subs + amp rack + monitors + mics β KES 36,000 per day.") | |
| add("wedding combo", "Wedding combo β KES 28,000 per day (line arrays + subs + monitors + mics + DJ console).") | |
| add("corporate combo", "Corporate combo β KES 18,000 per day (line arrays + subs + monitors + mics + projector).") | |
| # --- SERVICES & CONTACT --- | |
| add("contact", "Call/WhatsApp: +254 757 299 299. Office: +254 113 710584.") | |
| add("payment mpesa", "To book, send 50% deposit via MPESA to 0757 299 299. Balance on delivery.") | |
| # --- ABOUT US (long multi-paragraph) | |
| ABOUT_US = ( | |
| "About Us\n" | |
| "At LD Events Solution Company, we specialize in delivering high-quality sound & lighting solutions for events of all sizes. " | |
| "From concerts and corporate functions to private celebrations, our experienced team is dedicated to creating unforgettable experiences " | |
| "through cutting-edge technology, creative designs, and reliable services. We pride ourselves on professionalism, attention to detail, " | |
| "and a passion for making every event shineβliterally and figuratively.\n\n" | |
| "At LD Sounds & Lighting Company, we use only top-quality equipment to deliver clear sound, stunning lighting, and crisp visuals. Our gear " | |
| "is reliable, our team is experienced, and weβre committed to making every event look and sound its best. Weβre results-orientedβfocused on " | |
| "delivering impactful experiences through top-quality sound, lighting, and visuals. With reliable equipment and a skilled team, we ensure every " | |
| "event runs smoothly from setup to showtime.\n\n" | |
| "Why Choose LD Events Solutions?\n" | |
| "β’ Quality Equipment\nβ’ Results-Oriented\nβ’ Competitive Rental Prices\nβ’ Professional & Experienced Staff\nβ’ Timeliness & Reliability\n\n" | |
| "Highlights\n" | |
| "β’ Professional-grade sound & lighting rigs\nβ’ Wide range of tents & staging\nβ’ In-house photography & videography options\nβ’ Fast site visits & proposals\n\n" | |
| "Testimonials\n" | |
| "βLD Sounds & Lighting made our wedding unforgettable. The lighting was magical, the sound quality outstanding.β β Bishop Sam\n\n" | |
| "βWorking with LD Sounds & Lighting Company was seamless from start to finish. Their team delivered high-quality sound and visuals on time.β β Mr & Mrs Hassan\n\n" | |
| "Contact Us\n" | |
| "Phone: 0712074366 | 0113710584\n" | |
| "Address: Ruai, Nairobi\n" | |
| "LD Sounds & Lighting β Your Ultimate Event Partner." | |
| ) | |
| # add ABOUT_US to REPLY as a key | |
| add("about us", ABOUT_US) | |
| # ---------- ITEM ALIASES ---------- | |
| ITEM_ALIASES = { | |
| "drone": "drone 2h", | |
| "drone coverage": "drone 2h", | |
| "parcan": "parcan lights", | |
| "parcan light": "parcan lights", | |
| "moving head": "moving head", | |
| "line array": "line array", | |
| "led panel": "led screen panel", | |
| "led screen": "led screen panel", | |
| "dj": "dj console", | |
| "mc": "mc service", | |
| "stage": "stage 8x4", | |
| "a-frame tent": "a frame tent", | |
| "dome tent": "dome tent", | |
| "clear span tent": "clear span tent", | |
| "tent": "a frame tent", | |
| "photography": "photography 8h", | |
| "videography": "videography 4k", | |
| "single 18 sub": "single 18", | |
| "double 18 sub": "double 18", | |
| "monitor": "monitors", | |
| "cordless mic": "cordless mic", | |
| } | |
| # PART 3/4 | |
| """ | |
| Reply engine, booking flow, tent logic, price handling, and conversational polishing. | |
| """ | |
| # ---------- Quick lists for detection ---------- | |
| GREETINGS = ("hello", "hi", "hey", "good morning", "good evening", "greetings") | |
| GOODBYES = ("bye", "goodbye", "see you", "talk later", "thanks", "thank you") | |
| SERVICE_ASK = ("services", "what do you offer", "offer", "what can you do", "do you offer") | |
| GEAR_WORDS = ("sound", "light", "tent", "stage", "speaker", "sub", "array", "dj", "mic", "wedding", "concert", "event") | |
| LAMAKI_WORDS = ("lamaki", "construction", "build", "house", "renovation", "kitchen", "bathroom", "architect", "interior") | |
| # ---------- Track user booking drafts & call requests handled in PART1 ---------- | |
| # DRAFT_BOOKINGS, CONFIRMED_BOOKINGS, CALL_REQUESTS defined in Part 1 | |
| # ---------- Booking helpers ---------- | |
| def start_booking(user: str, intent: str) -> str: | |
| draft = DRAFT_BOOKINGS.setdefault(user, {}) | |
| draft.setdefault("intent", intent) | |
| draft.setdefault("created_at", datetime.datetime.utcnow().isoformat()) | |
| return "Sure β I can help book that. When would you like the booking? (send a date like 2025-12-31 or '25/12/2025')" | |
| def add_booking_detail(user: str, key: str, value: Any) -> None: | |
| draft = DRAFT_BOOKINGS.setdefault(user, {}) | |
| draft[key] = value | |
| draft["updated_at"] = datetime.datetime.utcnow().isoformat() | |
| def finalize_booking(user: str) -> str: | |
| draft = DRAFT_BOOKINGS.get(user) | |
| if not draft: | |
| return "I don't have any booking details yet. Tell me what you'd like to book." | |
| # minimal required fields | |
| required = ["date", "guests", "location"] | |
| missing = [k for k in required if k not in draft] | |
| if missing: | |
| return f"I need the following to finalize your booking: {', '.join(missing)}. Please provide them." | |
| code = gen_booking_code() | |
| CONFIRMED_BOOKINGS[code] = draft.copy() | |
| CONFIRMED_BOOKINGS[code]["confirmed_at"] = datetime.datetime.utcnow().isoformat() | |
| DRAFT_BOOKINGS.pop(user, None) | |
| return f"All set β your booking is confirmed with code {code}. Weβll call you shortly to confirm details." | |
| # ---------- Core reply engine ---------- | |
| # ---------- Core reply engine (improved) ---------- | |
| from difflib import get_close_matches | |
| def secretary_reply(text: str, user: str = "unknown") -> str: | |
| raw = text or "" | |
| t = normalize(raw) | |
| # --- Greetings --- | |
| if any(g in t for g in GREETINGS): | |
| return friendly("Hello! I'm here to help β tell me what you need for your event or construction project.") | |
| # --- Goodbyes --- | |
| if any(b in t for b in GOODBYES): | |
| return friendly("Thanks for reaching out β have a great day! If you need anything else, message me anytime.") | |
| # --- Call request detection --- | |
| if re.search(r"\b(call|contact|phone|call me|ring me)\b", t): | |
| CALL_REQUESTS[user] = True | |
| return friendly("Okay β Iβve requested a call. Our team will ring you shortly during business hours.") | |
| # --- About us / company inquiries --- | |
| if "about" in t or "about us" in t or "who are you" in t: | |
| return friendly(REPLY.get("about us", ABOUT_US)) | |
| # --- Services inquiry --- | |
| if any(q in t for q in SERVICE_ASK): | |
| if any(w in t for w in LAMAKI_WORDS): | |
| return friendly(LAMAKI_SERVICES) | |
| return friendly(LD_EVENTS_SERVICES) | |
| # --- Tent guest detection --- | |
| m = re.search(r"(tent|tents?).{0,20}?(\d{1,4})", raw, flags=re.I) | |
| if m: | |
| guests = int(m.group(2)) | |
| if guests <= 100: | |
| LAST_ITEM[user] = "a frame tent" | |
| return friendly(f"For {guests} guests I recommend our A-frame tent. {REPLY.get(normalize('a frame tent'))}") | |
| elif guests <= 200: | |
| LAST_ITEM[user] = "dome tent" | |
| return friendly(f"For {guests} guests I recommend our Dome tent. {REPLY.get(normalize('dome tent'))}") | |
| elif guests <= 500: | |
| LAST_ITEM[user] = "clear span tent" | |
| return friendly(f"For {guests} guests I recommend our Clear-span tent. {REPLY.get(normalize('clear span tent'))}") | |
| else: | |
| return friendly("We provide custom tent solutions for more than 500 guests β please share the guest count and venue location.") | |
| # --- Generic tent request --- | |
| if "tent" in t and "book" not in t: | |
| return friendly( | |
| "We offer A-frame (100 guests), Dome (200 guests), and Clear-span (500 guests) tents. " | |
| "Tell me the number of guests and Iβll pick the best option." | |
| ) | |
| # --- Price / Cost enquiries --- | |
| if any(w in t for w in ("price", "cost", "how much", "rates", "fee", "charge")): | |
| last = LAST_ITEM.get(user) | |
| if last and normalize(last) in REPLY: | |
| return friendly(REPLY[normalize(last)]) | |
| # try to find item words in text | |
| for key in REPLY.keys(): | |
| if key in t: | |
| LAST_ITEM[user] = key | |
| return friendly(REPLY[key]) | |
| return friendly("Please tell me which item or package you'd like the price for (e.g., 'gold package' or 'double 18').") | |
| # --- Generic gear suggestions --- | |
| if "mixer" in t: | |
| options = ["mixer wing", "mixer skytone"] | |
| suggestions = "\n".join(f"- {opt}: {REPLY[normalize(opt)]}" for opt in options) | |
| return friendly(f"We have the following mixers:\n{suggestions}") | |
| if "line array" in t or "line arrays" in t: | |
| return friendly(REPLY.get("line array", "Line arrays are available β please specify how many or which combo you want.")) | |
| if "sub" in t or "18" in t: | |
| subs = ["single 18", "double 18"] | |
| suggestions = "\n".join(f"- {opt}: {REPLY[normalize(opt)]}" for opt in subs) | |
| return friendly(f"We have the following subwoofers:\n{suggestions}") | |
| # --- Alias matching (safe) --- | |
| for alias, key in ITEM_ALIASES.items(): | |
| if alias in t: | |
| LAST_ITEM[user] = key | |
| reply_text = REPLY.get(normalize(key)) | |
| if not reply_text: | |
| reply_text = f"I found {key}, but price/description not available." | |
| return friendly(reply_text) | |
| # --- Exact REPLY keys match --- | |
| for k in list(REPLY.keys()): | |
| if k in t: | |
| LAST_ITEM[user] = k | |
| base = REPLY[k] | |
| if "package" in k or "combo" in k: | |
| base += " If you'd like to book this, tell me the date, approximate guest count, and venue." | |
| return friendly(base) | |
| # --- Fuzzy matching --- | |
| def fuzzy_lookup(text): | |
| keys = list(REPLY.keys()) + list(ITEM_ALIASES.keys()) | |
| matches = get_close_matches(text, keys, n=1, cutoff=0.5) | |
| if matches: | |
| return ITEM_ALIASES.get(matches[0], matches[0]) | |
| return None | |
| fuzzy_key = fuzzy_lookup(t) | |
| if fuzzy_key: | |
| LAST_ITEM[user] = fuzzy_key | |
| reply_text = REPLY.get(normalize(fuzzy_key), f"I found {fuzzy_key}, but price/description not available.") | |
| return friendly(reply_text) | |
| # --- Booking flow --- | |
| if re.search(r"\b(book|reserve|schedule|site visit|site-visit|sitevisit)\b", t): | |
| if "site" in t: | |
| return friendly(start_booking(user, "site_visit")) | |
| if "consult" in t: | |
| return friendly(start_booking(user, "consultation")) | |
| return friendly(start_booking(user, "event_booking")) | |
| dt = find_date_in_text(raw) | |
| if dt: | |
| add_booking_detail(user, "date", dt) | |
| return friendly(f"Got the date: {dt}. Where will the event be (venue)?") | |
| guests_m = re.search(r"(\d{2,4})\s*(guests|people|pax|attendees)", t) | |
| if guests_m: | |
| g = int(guests_m.group(1)) | |
| add_booking_detail(user, "guests", g) | |
| return friendly(f"Great β noted {g} guests. Whatβs the venue/location?") | |
| loc_m = re.search(r"\b(at|venue|in)\s+([a-z0-9\s]+(?:hall|center|grounds|arena|stadium|venue)?)", raw, flags=re.I) | |
| if loc_m: | |
| loc = loc_m.group(2).strip() | |
| add_booking_detail(user, "location", loc) | |
| return friendly(f"Noted venue: {loc}. Any special requests or additional gear?") | |
| if any(pat in t for pat in ("confirm booking", "finalize booking", "complete booking", "confirm reservation", "confirm my booking")): | |
| res = finalize_booking(user) | |
| return friendly(res) | |
| mcode = re.search(r"\b([A-F0-9]{8})\b", raw, flags=re.I) | |
| if mcode: | |
| code = mcode.group(1).upper() | |
| if code in CONFIRMED_BOOKINGS: | |
| info = CONFIRMED_BOOKINGS[code] | |
| return friendly(f"Booking {code} was created on {info.get('confirmed_at')}. Details: {info}") | |
| return friendly("Sorry, I can't find that booking code. Please check and send it again.") | |
| if any(w in t for w in LAMAKI_WORDS): | |
| return friendly(LAMAKI_SERVICES) | |
| if len(t.split()) <= 2: | |
| return friendly("Could you provide a few more details? For example: 'price for silver package' or 'book site visit 2025-12-05 200 guests at ABC Hall'.") | |
| return friendly("Iβm here to help β tell me the item, package, or service you want and Iβll assist from there.") | |
| """ | |
| Flask endpoints, health check, and run instructions. | |
| """ | |
| app = Flask(__name__) | |
| def whatsapp(): | |
| j = request.json or {} | |
| if j.get("verify") != VERIFY_TOKEN: | |
| return jsonify(error="bad token"), 403 | |
| user = j.get("from", "unknown") | |
| msg = (j.get("text") or "").strip() | |
| if not msg: | |
| return jsonify(reply="Please send a short message describing what you need (e.g., 'silver package price' or 'book site visit on 2025-12-10 for 200 people').") | |
| # save incoming | |
| save_msg(user, msg, "user") | |
| try: | |
| ans = secretary_reply(msg, user) | |
| except Exception as e: | |
| log.exception("Reply generation failed: %s", e) | |
| ans = "Sorry β something went wrong while I prepared the response. Try again or call +254 757 299 299." | |
| save_msg(user, ans, "assistant") | |
| return jsonify(reply=ans) | |
| def health(): | |
| return "ok\n" | |
| if __name__ == "__main__": | |
| log.info("Starting LD Secretary Bot on port %s", PORT) | |
| app.run(host="0.0.0.0", port=PORT, threaded=True) | |