Implement renter search, previews and simulated payment flows
Browse files- README.md +14 -0
- main.py +218 -117
- test_flows.py +204 -0
- utility.py +29 -11
- whatsapp_client.py +1 -1
README.md
CHANGED
|
@@ -8,3 +8,17 @@ pinned: false
|
|
| 8 |
---
|
| 9 |
|
| 10 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
| 11 |
+
|
| 12 |
+
## Renter flows
|
| 13 |
+
|
| 14 |
+
The finder offers step-by-step and natural-language search. Four steps collect property type, area, monthly USD budget and optional features, followed by a search summary with editable requirements. Numbered text menus work with the existing Twilio transport; lists retain every option. Photo messages replace the mockup's embedded carousel and cards.
|
| 15 |
+
|
| 16 |
+
Search is free, with three numbered results per page and up to two free preview photos. A $10 Rental Pass supplies three distinct property unlocks for 14 days. Payment confirmation preserves the selected property and offers View property now or Continue browsing. Viewing an already unlocked home uses no additional credit. Full details include the gallery, optional `video_url`, `rental_requirements` and provider information. Missing video or requirements are handled without inventing content.
|
| 17 |
+
|
| 18 |
+
A $5 viewing request explains the $2 provider / $3 platform split. Successful simulated payment creates an appointment-pending request for operations to coordinate. It does not claim that a viewing time is booked. Provider contact is shared in that confirmation. Property questions enter the existing support collection.
|
| 19 |
+
|
| 20 |
+
Payments remain spoofed. The user explicitly confirms a demo payment; no EcoCash transfer or live payment integration occurs.
|
| 21 |
+
|
| 22 |
+
## Verification
|
| 23 |
+
|
| 24 |
+
Run `python -m unittest test_flows -q`. The conversation tests use local memory and mocked outbound messages, with no Firebase, Gemini, WhatsApp or payment calls. Deploy bot changes together with the admin API and UI updates that persist optional gallery/video/requirements fields.
|
main.py
CHANGED
|
@@ -105,11 +105,11 @@ WELCOME = (
|
|
| 105 |
|
| 106 |
MAIN_OPTS = ["Find a place to rent", "List my property", "Talk to support"]
|
| 107 |
|
| 108 |
-
FIND_TYPES = ["Single Room", "Cottage", "1 Bedroom", "2 Bedroom"]
|
| 109 |
-
FIND_AREAS = ["
|
| 110 |
FIND_BUDGETS = [("Under $200", 200), ("$200 - $400", 400), ("$400 - $600", 600),
|
| 111 |
-
("$600 - $800", 800), ("
|
| 112 |
-
FIND_AMENS = ["
|
| 113 |
PAGE = 3
|
| 114 |
|
| 115 |
|
|
@@ -142,74 +142,136 @@ def my_account_text(phone: str) -> str:
|
|
| 142 |
|
| 143 |
# ── Finder + landlord + support flows (story order) ─────────────────
|
| 144 |
def start_find(m: str, prefill: Optional[Dict] = None) -> None:
|
| 145 |
-
"""Guided search. Every step accepts a number OR free text."""
|
| 146 |
d = {"type": "", "suburb": "", "max_budget": 0, "amenities": []}
|
| 147 |
-
if prefill:
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
"or answer step by step.")
|
| 154 |
-
wa.send_buttons_as_text(m, "What type of property are you looking for?", FIND_TYPES)
|
| 155 |
-
set_s(m, {"menu": "find", "step": "type", "data": d})
|
| 156 |
return
|
|
|
|
| 157 |
_find_next(m, d)
|
| 158 |
|
| 159 |
|
| 160 |
def _find_next(m: str, d: Dict) -> None:
|
| 161 |
-
if not d.get("
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
| 168 |
else:
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
|
| 174 |
|
| 175 |
def _finish_find(m: str, d: Dict) -> None:
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
|
| 184 |
def handle_find(m: str, text: str, s: Dict) -> bool:
|
| 185 |
-
|
| 186 |
-
step, d = s.get("step", ""), s.get("data", {})
|
| 187 |
t = text.strip()
|
| 188 |
-
|
| 189 |
-
if step
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
else:
|
| 195 |
-
start_find(m,
|
| 196 |
return True
|
| 197 |
if step == "type":
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
| 200 |
elif step == "suburb":
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
| 205 |
elif step == "budget":
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
elif step == "amen":
|
|
|
|
|
|
|
|
|
|
| 209 |
d["amenities"] = _pick_multi(t, FIND_AMENS)
|
| 210 |
-
|
| 211 |
else:
|
| 212 |
-
|
|
|
|
|
|
|
| 213 |
return True
|
| 214 |
|
| 215 |
|
|
@@ -241,19 +303,19 @@ def _pick_multi(t: str, opts: list) -> list:
|
|
| 241 |
if t.lower() in ("none", "none of these", "7", "skip"):
|
| 242 |
return []
|
| 243 |
out = []
|
| 244 |
-
for part in re.split(r"[, ]+", t):
|
| 245 |
part = part.strip()
|
| 246 |
if part.isdigit() and 1 <= int(part) <= len(opts):
|
| 247 |
o = opts[int(part) - 1]
|
| 248 |
-
if o
|
| 249 |
out.append(o)
|
| 250 |
elif part:
|
| 251 |
hit = next((o for o in opts if part.lower() in o.lower()), None)
|
| 252 |
-
if hit and hit
|
| 253 |
out.append(hit)
|
| 254 |
if not out and t.lower() not in ("none",):
|
| 255 |
c = U.parse_search(t)
|
| 256 |
-
out = [a.title() for a in c["amenities"]]
|
| 257 |
return out
|
| 258 |
|
| 259 |
|
|
@@ -270,12 +332,14 @@ def start_create_listing(m: str) -> None:
|
|
| 270 |
def story_results_text(res: list, shown: int) -> str:
|
| 271 |
lines = []
|
| 272 |
for i, r in enumerate(res[:shown], 1):
|
| 273 |
-
|
|
|
|
|
|
|
| 274 |
lines.append("")
|
| 275 |
left = len(res) - shown
|
| 276 |
if left > 0:
|
| 277 |
lines.append(f"_View more properties ({left} remaining): reply MORE_")
|
| 278 |
-
lines.append("Reply a number for
|
| 279 |
return "\n".join(lines)
|
| 280 |
|
| 281 |
|
|
@@ -286,40 +350,55 @@ def show_results(m: str, crit: Dict) -> None:
|
|
| 286 |
set_s(m, {"menu": "browse", "results": [], "shown": 0, "criteria": crit})
|
| 287 |
return
|
| 288 |
wa.send_text_message(m, U.gemini_ack_search(crit, len(res)))
|
| 289 |
-
|
| 290 |
-
note = f"{len(exact)} exact matches" if exact else f"{len(res)} matches"
|
| 291 |
-
near = len(res) - len(exact)
|
| 292 |
-
if near > 0:
|
| 293 |
-
note += f" + {near} nearby alternatives"
|
| 294 |
-
wa.send_text_message(m, f"*{note}.*\nYour search: {crit.get('type','Any')} in {crit.get('suburb','Any area')}"
|
| 295 |
-
+ (f" up to ${int(crit['max_budget'])}" if crit.get("max_budget") else ""))
|
| 296 |
shown = min(PAGE, len(res))
|
| 297 |
-
|
| 298 |
set_s(m, {"menu": "browse", "results": [r["id"] for r in res], "shown": shown, "criteria": crit})
|
| 299 |
|
| 300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
def show_detail(m: str, lid: str, n: int) -> None:
|
| 302 |
item = U.get_listing(lid)
|
| 303 |
if not item:
|
| 304 |
wa.send_text_message(m, "That home just left the market. Reply MORE for others.")
|
| 305 |
return
|
|
|
|
|
|
|
| 306 |
if U.is_unlocked(m, lid):
|
| 307 |
send_full_details(m, item, n)
|
| 308 |
return
|
| 309 |
-
wa.send_text_message(m, U.format_preview(item, n
|
| 310 |
-
|
| 311 |
-
|
|
|
|
|
|
|
|
|
|
| 312 |
|
| 313 |
|
| 314 |
def send_full_details(m: str, item: Dict, n: int = 0) -> None:
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
| 317 |
wa.send_image_message(m, url, item.get("title", ""))
|
| 318 |
-
|
| 319 |
-
m, "
|
| 320 |
-
|
|
|
|
| 321 |
s = get_s(m) or {}
|
| 322 |
-
set_s(m, {**s, "menu": "browse", "detail": item["id"], "detail_n": n,
|
| 323 |
"results": s.get("results", []), "shown": s.get("shown", PAGE),
|
| 324 |
"criteria": s.get("criteria", {})})
|
| 325 |
|
|
@@ -364,6 +443,7 @@ def handle_need_name(m: str, text: str, s: Dict) -> bool:
|
|
| 364 |
pend = s.get("pending", {})
|
| 365 |
lid = pend.get("pending_unlock", "")
|
| 366 |
if lid:
|
|
|
|
| 367 |
buy_pass(m, pending_unlock=lid)
|
| 368 |
return True
|
| 369 |
set_s(m, pend if pend.get("menu") else {"menu": "main"})
|
|
@@ -500,42 +580,74 @@ def buy_pass(m: str, pending_unlock: str = "") -> None:
|
|
| 500 |
wa.send_text_message(
|
| 501 |
m, f"{head}"
|
| 502 |
"Unlock 3 properties. Full photos, video and details. Valid for 14 days.\n"
|
| 503 |
-
"
|
|
|
|
| 504 |
f"{U.ecocash_instructions(pay)}")
|
| 505 |
-
wa.send_buttons_as_text(m, "
|
| 506 |
-
st: Dict = {"menu": "await_pay", "payment_id": pay["id"], "kind": "rental_pass"}
|
| 507 |
if pending_unlock:
|
| 508 |
st["pending_unlock"] = pending_unlock
|
| 509 |
set_s(m, st)
|
| 510 |
|
| 511 |
|
| 512 |
def buy_viewing(m: str, listing_id: str) -> None:
|
|
|
|
|
|
|
|
|
|
| 513 |
pay = U.create_payment(m, "viewing", U.VIEWING_FEE_CENTS, {"listing_id": listing_id})
|
| 514 |
-
wa.send_text_message(m, f"*Physical viewing $5* for [{listing_id}].\n$2 goes to the landlord, $3 to the platform. Our team coordinates the
|
| 515 |
-
wa.send_buttons_as_text(m, "
|
| 516 |
-
set_s(m, {"menu": "await_pay", "payment_id": pay["id"], "kind": "viewing", "listing_id": listing_id})
|
| 517 |
|
| 518 |
|
| 519 |
def handle_browse(m: str, text: str, s: Dict) -> bool:
|
| 520 |
t = text.strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
ids = s.get("results", [])
|
| 522 |
shown = s.get("shown", PAGE)
|
| 523 |
if t in ("new", "new search"):
|
| 524 |
start_find(m)
|
| 525 |
return True
|
| 526 |
if t in ("more", "view more", "view more properties"):
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
set_s(m, {**s, "shown":
|
| 530 |
return True
|
| 531 |
if t in ("back", "back to results"):
|
| 532 |
wa.send_text_message(m, story_results_text([U.get_listing(i) for i in ids], shown))
|
| 533 |
-
set_s(m, {**s, "detail": "", "detail_n": 0})
|
| 534 |
return True
|
| 535 |
-
if t in ("pass", "get rental pass", "rental pass", "payment done"
|
| 536 |
buy_pass(m)
|
| 537 |
return True
|
| 538 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
det_id = s.get("detail", "")
|
| 540 |
if "request viewing" in t and det_id:
|
| 541 |
if not U.is_unlocked(m, det_id):
|
|
@@ -607,19 +719,7 @@ def handle_browse(m: str, text: str, s: Dict) -> bool:
|
|
| 607 |
n = int(t)
|
| 608 |
if 1 <= n <= len(ids):
|
| 609 |
show_detail(m, ids[n - 1], n)
|
| 610 |
-
set_s(m, {**s, "detail": ids[n - 1], "detail_n": n})
|
| 611 |
return True
|
| 612 |
-
# number answers inside detail options map to option rows
|
| 613 |
-
det = s.get("detail_n") or 0
|
| 614 |
-
if det and n in (1, 2, 3):
|
| 615 |
-
lid = ids[det - 1]
|
| 616 |
-
if n == 1:
|
| 617 |
-
return handle_browse(m, f"unlock {det}", s)
|
| 618 |
-
if n == 2:
|
| 619 |
-
return handle_browse(m, "back", s)
|
| 620 |
-
if n == 3 and U.is_unlocked(m, lid):
|
| 621 |
-
buy_viewing(m, lid)
|
| 622 |
-
return True
|
| 623 |
return False
|
| 624 |
|
| 625 |
|
|
@@ -652,6 +752,10 @@ def _process_text_inner(text: str, mobile: str) -> None:
|
|
| 652 |
low = text.strip().lower()
|
| 653 |
logger.info(f"STEP route from={mobile} session={(s or {}).get('menu')}/{str((s or {}).get('step'))}")
|
| 654 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
if GREET.match(text.strip()) and (not s or s.get("menu") in ("main", "post_reg")):
|
| 656 |
logger.info(f"STEP welcome from={mobile}")
|
| 657 |
send_welcome(mobile)
|
|
@@ -707,7 +811,7 @@ def _process_text_inner(text: str, mobile: str) -> None:
|
|
| 707 |
return
|
| 708 |
if menu == "await_pay":
|
| 709 |
logger.info(f"STEP pay-confirm from={mobile} kind={s.get('kind')} low={low!r}")
|
| 710 |
-
if low in ("1", "payment done", "i have paid", "paid", "confirm", "pay"):
|
| 711 |
pay = U.confirm_payment_spoof(s["payment_id"], text)
|
| 712 |
logger.info(f"STEP pay-status from={mobile} pay={(pay or {}).get('id')} status={(pay or {}).get('status')}")
|
| 713 |
if not pay or pay.get("status") != "confirmed":
|
|
@@ -716,29 +820,26 @@ def _process_text_inner(text: str, mobile: str) -> None:
|
|
| 716 |
if s.get("kind") == "rental_pass":
|
| 717 |
p = U.issue_pass(mobile, pay["id"])
|
| 718 |
left = p["credits_total"] - p["credits_used"]
|
| 719 |
-
wa.send_text_message(mobile, f"
|
| 720 |
-
|
| 721 |
-
if
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
item = U.get_listing(lid)
|
| 726 |
-
n = _num_of(s, lid)
|
| 727 |
-
send_full_details(mobile, item, n)
|
| 728 |
-
wa.send_text_message(mobile, f"_{msg}_")
|
| 729 |
-
return
|
| 730 |
-
clear_s(mobile)
|
| 731 |
-
start_find(mobile)
|
| 732 |
else:
|
| 733 |
v = U.create_viewing(mobile, s.get("listing_id", ""), pay["id"])
|
| 734 |
-
|
| 735 |
-
|
|
|
|
|
|
|
| 736 |
return
|
| 737 |
-
if low in ("2", "cancel"):
|
| 738 |
-
|
| 739 |
-
wa.send_text_message(mobile, "
|
|
|
|
| 740 |
return
|
| 741 |
-
|
|
|
|
| 742 |
|
| 743 |
# keyword shortcuts (no session)
|
| 744 |
logger.info(f"STEP shortcuts from={mobile} low={low!r}")
|
|
|
|
| 105 |
|
| 106 |
MAIN_OPTS = ["Find a place to rent", "List my property", "Talk to support"]
|
| 107 |
|
| 108 |
+
FIND_TYPES = ["Single Room", "Cottage / Garden Flat", "1 Bedroom", "2 Bedroom", "House / Townhouse"]
|
| 109 |
+
FIND_AREAS = ["Mount Pleasant", "Avondale", "Borrowdale", "Westgate", "Highlands", "Hatcliffe", "Other (type suburb)", "Any area"]
|
| 110 |
FIND_BUDGETS = [("Under $200", 200), ("$200 - $400", 400), ("$400 - $600", 600),
|
| 111 |
+
("$600 - $800", 800), ("Over $800", 0), ("Any (show all)", 0)]
|
| 112 |
+
FIND_AMENS = ["Borehole", "Solar", "Parking", "Ensuite", "Pet friendly", "Gated community", "None", "Other (type)"]
|
| 113 |
PAGE = 3
|
| 114 |
|
| 115 |
|
|
|
|
| 142 |
|
| 143 |
# ── Finder + landlord + support flows (story order) ─────────────────
|
| 144 |
def start_find(m: str, prefill: Optional[Dict] = None) -> None:
|
|
|
|
| 145 |
d = {"type": "", "suburb": "", "max_budget": 0, "amenities": []}
|
| 146 |
+
if prefill is None:
|
| 147 |
+
wa.send_buttons_as_text(m,
|
| 148 |
+
"Great! Let's find your next home.\nChoose step by step, or type naturally at any time.\n"
|
| 149 |
+
'Example: "1 bed cottage in Mount Pleasant with borehole, solar and parking under $400".',
|
| 150 |
+
["Step by step", "Type my preferences"])
|
| 151 |
+
set_s(m, {"menu": "find", "step": "mode", "data": d})
|
|
|
|
|
|
|
|
|
|
| 152 |
return
|
| 153 |
+
d.update(prefill)
|
| 154 |
_find_next(m, d)
|
| 155 |
|
| 156 |
|
| 157 |
def _find_next(m: str, d: Dict) -> None:
|
| 158 |
+
if not d.get("type"):
|
| 159 |
+
step, prompt, options = "type", "Step 1 of 4: What type of home are you looking for?", FIND_TYPES
|
| 160 |
+
elif not d.get("suburb") and not d.get("area_chosen"):
|
| 161 |
+
step, prompt, options = "suburb", "Step 2 of 4: Which area? You can type any suburb.", FIND_AREAS
|
| 162 |
+
elif not d.get("max_budget") and not d.get("budget_chosen"):
|
| 163 |
+
step, prompt, options = "budget", "Step 3 of 4: What's your monthly budget (USD)? Or type an amount.", [b[0] for b in FIND_BUDGETS]
|
| 164 |
+
elif not d.get("features_chosen"):
|
| 165 |
+
step, prompt, options = "amen", "Step 4 of 4: Any preferred features? Reply 1,2,3 or 123, type features, or SKIP.", FIND_AMENS
|
| 166 |
else:
|
| 167 |
+
_finish_find(m, d)
|
| 168 |
+
return
|
| 169 |
+
wa.send_buttons_as_text(m, prompt, options)
|
| 170 |
+
set_s(m, {"menu": "find", "step": step, "data": d})
|
| 171 |
|
| 172 |
|
| 173 |
def _finish_find(m: str, d: Dict) -> None:
|
| 174 |
+
"""Confirm our understanding before running a search."""
|
| 175 |
+
d = {**d, "features_chosen": True}
|
| 176 |
+
budget = (f"${d.get('min_budget', 0):g} - ${d['max_budget']:g}/month" if d.get("max_budget")
|
| 177 |
+
else "Over $800/month" if d.get("min_budget") else "Any budget")
|
| 178 |
+
wa.send_buttons_as_text(m,
|
| 179 |
+
"*Here's what I'm looking for:*\n"
|
| 180 |
+
f"Home: {d.get('type') or 'Any type'}\nArea: {d.get('suburb') or 'Any area'}\n"
|
| 181 |
+
f"Budget: {budget}\nFeatures: {', '.join(d.get('amenities', [])) or 'No preference'}\n\n"
|
| 182 |
+
"Shall I search for properties that match this? You can also type new preferences.",
|
| 183 |
+
["Search now", "Change requirements"])
|
| 184 |
+
set_s(m, {"menu": "find", "step": "summary", "data": d})
|
| 185 |
|
| 186 |
|
| 187 |
def handle_find(m: str, text: str, s: Dict) -> bool:
|
| 188 |
+
step, d = s.get("step", ""), dict(s.get("data", {}))
|
|
|
|
| 189 |
t = text.strip()
|
| 190 |
+
low = t.lower()
|
| 191 |
+
if step == "summary":
|
| 192 |
+
if low in ("1", "search now", "search", "yes"):
|
| 193 |
+
prefs = {k: d.get(k, default) for k, default in
|
| 194 |
+
(("type", ""), ("suburb", ""), ("min_budget", 0), ("max_budget", 0), ("amenities", []))}
|
| 195 |
+
p = U.get_profile(m)
|
| 196 |
+
U.save_profile(m, {"roles": sorted(set(p.get("roles", []) + ["seeker"])), "prefs": prefs})
|
| 197 |
+
show_results(m, prefs)
|
| 198 |
+
return True
|
| 199 |
+
if low in ("2", "change requirements", "change", "edit"):
|
| 200 |
+
wa.send_buttons_as_text(m, "What would you like to change?", ["Property type", "Location", "Budget", "Features", "Start over"])
|
| 201 |
+
set_s(m, {"menu": "find", "step": "edit", "data": d})
|
| 202 |
+
return True
|
| 203 |
+
if step == "edit":
|
| 204 |
+
choice = _pick(t, ["Property type", "Location", "Budget", "Features", "Start over"], "edit")
|
| 205 |
+
if choice == "Start over":
|
| 206 |
+
start_find(m, {})
|
| 207 |
+
return True
|
| 208 |
+
keys = {"Property type": ("type",), "Location": ("suburb", "area_chosen"),
|
| 209 |
+
"Budget": ("min_budget", "max_budget", "budget_chosen"), "Features": ("features_chosen",)}
|
| 210 |
+
if choice in keys:
|
| 211 |
+
for key in keys[choice]:
|
| 212 |
+
d.pop(key, None)
|
| 213 |
+
_find_next(m, d)
|
| 214 |
+
return True
|
| 215 |
+
# Natural descriptions work in every finder step, including preferences and summary.
|
| 216 |
+
if not t.isdigit() and len(t.split()) > 2 and U.looks_like_search(t):
|
| 217 |
+
parsed = U.parse_search(t)
|
| 218 |
+
d.update({k: v for k, v in parsed.items() if v})
|
| 219 |
+
if parsed.get("max_budget"):
|
| 220 |
+
d["min_budget"] = parsed.get("min_budget", 0)
|
| 221 |
+
if parsed.get("amenities"):
|
| 222 |
+
d["features_chosen"] = True
|
| 223 |
+
_find_next(m, d)
|
| 224 |
+
return True
|
| 225 |
+
if step == "mode":
|
| 226 |
+
if low in ("1", "step by step"):
|
| 227 |
+
_find_next(m, d)
|
| 228 |
+
elif low in ("2", "type my preferences"):
|
| 229 |
+
wa.send_text_message(m, "Tell me the type, area, monthly budget and features you want. Reply BACK for step by step.")
|
| 230 |
+
set_s(m, {"menu": "find", "step": "natural", "data": d})
|
| 231 |
+
else:
|
| 232 |
+
wa.send_text_message(m, "Reply 1 for step by step, or type your preferences.")
|
| 233 |
+
return True
|
| 234 |
+
if step == "natural":
|
| 235 |
+
if low == "back":
|
| 236 |
+
_find_next(m, d)
|
| 237 |
else:
|
| 238 |
+
start_find(m, U.parse_search(t))
|
| 239 |
return True
|
| 240 |
if step == "type":
|
| 241 |
+
chosen = _pick(t, FIND_TYPES, "type")
|
| 242 |
+
if t.isdigit() and not 1 <= int(t) <= len(FIND_TYPES):
|
| 243 |
+
wa.send_text_message(m, "Choose a property type from 1 to 5, or type a home type.")
|
| 244 |
+
return True
|
| 245 |
+
d["type"] = {"Cottage / Garden Flat": "cottage", "House / Townhouse": "house"}.get(chosen, chosen)
|
| 246 |
elif step == "suburb":
|
| 247 |
+
if low in ("7", "other", "other (type suburb)"):
|
| 248 |
+
wa.send_text_message(m, "Type the suburb you'd like.")
|
| 249 |
+
return True
|
| 250 |
+
d["suburb"] = "" if low in ("8", "any area", "any") else _pick(t, FIND_AREAS, "suburb")
|
| 251 |
+
d["area_chosen"] = True
|
| 252 |
elif step == "budget":
|
| 253 |
+
if low in ("6", "any", "any budget", "any (show all)"):
|
| 254 |
+
d.update(max_budget=0, min_budget=0)
|
| 255 |
+
elif low in ("5", "over $800", "above $800"):
|
| 256 |
+
d.update(max_budget=0, min_budget=800.01)
|
| 257 |
+
else:
|
| 258 |
+
choice = int(t)-1 if t.isdigit() and 1 <= int(t) <= 4 else None
|
| 259 |
+
lo, hi = U.parse_budget(t) if choice is None else (choice * 200, FIND_BUDGETS[choice][1])
|
| 260 |
+
if not hi or hi <= 0:
|
| 261 |
+
wa.send_text_message(m, "Choose a budget option or type a positive amount, e.g. 350.")
|
| 262 |
+
return True
|
| 263 |
+
d.update(max_budget=hi, min_budget=lo or 0)
|
| 264 |
+
d["budget_chosen"] = True
|
| 265 |
elif step == "amen":
|
| 266 |
+
if low in ("8", "other", "other (type)"):
|
| 267 |
+
wa.send_text_message(m, "Type your preferred features, or SKIP.")
|
| 268 |
+
return True
|
| 269 |
d["amenities"] = _pick_multi(t, FIND_AMENS)
|
| 270 |
+
d["features_chosen"] = True
|
| 271 |
else:
|
| 272 |
+
wa.send_text_message(m, "Reply with an option above or type your new preferences.")
|
| 273 |
+
return True
|
| 274 |
+
_find_next(m, d)
|
| 275 |
return True
|
| 276 |
|
| 277 |
|
|
|
|
| 303 |
if t.lower() in ("none", "none of these", "7", "skip"):
|
| 304 |
return []
|
| 305 |
out = []
|
| 306 |
+
for part in (list(t) if t.isdigit() and len(t) > 1 else re.split(r"[, ]+", t)):
|
| 307 |
part = part.strip()
|
| 308 |
if part.isdigit() and 1 <= int(part) <= len(opts):
|
| 309 |
o = opts[int(part) - 1]
|
| 310 |
+
if o not in ("None", "None of these", "Other (type)") and o not in out:
|
| 311 |
out.append(o)
|
| 312 |
elif part:
|
| 313 |
hit = next((o for o in opts if part.lower() in o.lower()), None)
|
| 314 |
+
if hit and hit not in ("None", "None of these", "Other (type)") and hit not in out:
|
| 315 |
out.append(hit)
|
| 316 |
if not out and t.lower() not in ("none",):
|
| 317 |
c = U.parse_search(t)
|
| 318 |
+
out = [a.title() for a in c["amenities"]] or [t]
|
| 319 |
return out
|
| 320 |
|
| 321 |
|
|
|
|
| 332 |
def story_results_text(res: list, shown: int) -> str:
|
| 333 |
lines = []
|
| 334 |
for i, r in enumerate(res[:shown], 1):
|
| 335 |
+
if not r:
|
| 336 |
+
continue
|
| 337 |
+
lines.append(U.format_preview(r, i, r.get("_score"), r.get("_reasons")))
|
| 338 |
lines.append("")
|
| 339 |
left = len(res) - shown
|
| 340 |
if left > 0:
|
| 341 |
lines.append(f"_View more properties ({left} remaining): reply MORE_")
|
| 342 |
+
lines.append("Reply a property number for a free preview, UNLOCK n to unlock, MORE for more homes, or NEW to search.")
|
| 343 |
return "\n".join(lines)
|
| 344 |
|
| 345 |
|
|
|
|
| 350 |
set_s(m, {"menu": "browse", "results": [], "shown": 0, "criteria": crit})
|
| 351 |
return
|
| 352 |
wa.send_text_message(m, U.gemini_ack_search(crit, len(res)))
|
| 353 |
+
wa.send_text_message(m, f"I found {len(res)} matching properties. Here are the first {min(PAGE, len(res))}.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
shown = min(PAGE, len(res))
|
| 355 |
+
send_result_page(m, res, 0, shown)
|
| 356 |
set_s(m, {"menu": "browse", "results": [r["id"] for r in res], "shown": shown, "criteria": crit})
|
| 357 |
|
| 358 |
|
| 359 |
+
def send_result_page(m: str, results: list, start: int, end: int) -> None:
|
| 360 |
+
for index in range(start, end):
|
| 361 |
+
item = results[index]
|
| 362 |
+
if not item:
|
| 363 |
+
continue
|
| 364 |
+
caption = U.format_preview(item, index + 1)
|
| 365 |
+
photos = item.get("media") or []
|
| 366 |
+
if not photos or not wa.send_image_message(m, photos[0], caption):
|
| 367 |
+
wa.send_text_message(m, caption)
|
| 368 |
+
wa.send_text_message(m, "Reply a property number for a free preview. UNLOCK n for full details with a $10 Rental Pass.\n"
|
| 369 |
+
+ ("MORE for more homes. " if end < len(results) else "") + "NEW for a new search.")
|
| 370 |
+
|
| 371 |
+
|
| 372 |
def show_detail(m: str, lid: str, n: int) -> None:
|
| 373 |
item = U.get_listing(lid)
|
| 374 |
if not item:
|
| 375 |
wa.send_text_message(m, "That home just left the market. Reply MORE for others.")
|
| 376 |
return
|
| 377 |
+
s = get_s(m) or {}
|
| 378 |
+
set_s(m, {**s, "menu": "browse", "detail": lid, "detail_n": n, "screen": "preview"})
|
| 379 |
if U.is_unlocked(m, lid):
|
| 380 |
send_full_details(m, item, n)
|
| 381 |
return
|
| 382 |
+
wa.send_text_message(m, U.format_preview(item, n))
|
| 383 |
+
for url in (item.get("media") or [])[:2]:
|
| 384 |
+
wa.send_image_message(m, url, "Free preview")
|
| 385 |
+
wa.send_buttons_as_text(m,
|
| 386 |
+
"More available after unlock: full photo gallery, video (where available), description, rental requirements and provider information.",
|
| 387 |
+
["Unlock property", "Back to results"])
|
| 388 |
|
| 389 |
|
| 390 |
def send_full_details(m: str, item: Dict, n: int = 0) -> None:
|
| 391 |
+
ap = U.active_pass(m)
|
| 392 |
+
left = ap["credits_total"] - ap["credits_used"] if ap else 0
|
| 393 |
+
wa.send_text_message(m, f"*{left} of 3 unlocks remaining*\n\n{U.format_full(item)}")
|
| 394 |
+
for url in (item.get("media") or []):
|
| 395 |
wa.send_image_message(m, url, item.get("title", ""))
|
| 396 |
+
if item.get("video_url"):
|
| 397 |
+
wa.send_document_message(m, doc_url=item["video_url"], caption="Video walkthrough")
|
| 398 |
+
wa.send_buttons_as_text(m, "Want to see it in person? RentNow coordinates your viewing.",
|
| 399 |
+
["Request viewing - $5", "Back to results", "Ask a question", "Save property"])
|
| 400 |
s = get_s(m) or {}
|
| 401 |
+
set_s(m, {**s, "menu": "browse", "detail": item["id"], "detail_n": n, "screen": "full",
|
| 402 |
"results": s.get("results", []), "shown": s.get("shown", PAGE),
|
| 403 |
"criteria": s.get("criteria", {})})
|
| 404 |
|
|
|
|
| 443 |
pend = s.get("pending", {})
|
| 444 |
lid = pend.get("pending_unlock", "")
|
| 445 |
if lid:
|
| 446 |
+
set_s(m, pend)
|
| 447 |
buy_pass(m, pending_unlock=lid)
|
| 448 |
return True
|
| 449 |
set_s(m, pend if pend.get("menu") else {"menu": "main"})
|
|
|
|
| 580 |
wa.send_text_message(
|
| 581 |
m, f"{head}"
|
| 582 |
"Unlock 3 properties. Full photos, video and details. Valid for 14 days.\n"
|
| 583 |
+
"Includes rental requirements and provider information. Use unlocks on any properties.\n"
|
| 584 |
+
"One-off pass. No subscription. You can continue browsing free.\n\n"
|
| 585 |
f"{U.ecocash_instructions(pay)}")
|
| 586 |
+
wa.send_buttons_as_text(m, "Demo payment. No money is charged.", ["Pay with EcoCash (demo)", "Continue browsing"])
|
| 587 |
+
st: Dict = {**(get_s(m) or {}), "menu": "await_pay", "payment_id": pay["id"], "kind": "rental_pass"}
|
| 588 |
if pending_unlock:
|
| 589 |
st["pending_unlock"] = pending_unlock
|
| 590 |
set_s(m, st)
|
| 591 |
|
| 592 |
|
| 593 |
def buy_viewing(m: str, listing_id: str) -> None:
|
| 594 |
+
if not U.is_unlocked(m, listing_id):
|
| 595 |
+
wa.send_text_message(m, "Unlock this property before requesting a viewing.")
|
| 596 |
+
return
|
| 597 |
pay = U.create_payment(m, "viewing", U.VIEWING_FEE_CENTS, {"listing_id": listing_id})
|
| 598 |
+
wa.send_text_message(m, f"*Physical viewing $5* for [{listing_id}].\n$2 goes to the landlord, $3 to the platform. Our team confirms availability, sends the provider your request and coordinates the date and time. Provider contact details are shared after payment.\n\n{U.ecocash_instructions(pay)}")
|
| 599 |
+
wa.send_buttons_as_text(m, "Demo payment. No money is charged.", ["Pay with EcoCash (demo)", "Continue browsing"])
|
| 600 |
+
set_s(m, {**(get_s(m) or {}), "menu": "await_pay", "payment_id": pay["id"], "kind": "viewing", "listing_id": listing_id})
|
| 601 |
|
| 602 |
|
| 603 |
def handle_browse(m: str, text: str, s: Dict) -> bool:
|
| 604 |
t = text.strip().lower()
|
| 605 |
+
if t.isdigit() and s.get("screen") in ("preview", "full"):
|
| 606 |
+
options = ["unlock property", "back to results"] if s["screen"] == "preview" else ["request viewing", "back to results", "ask a question", "save property"]
|
| 607 |
+
if 1 <= int(t) <= len(options):
|
| 608 |
+
t = options[int(t) - 1]
|
| 609 |
ids = s.get("results", [])
|
| 610 |
shown = s.get("shown", PAGE)
|
| 611 |
if t in ("new", "new search"):
|
| 612 |
start_find(m)
|
| 613 |
return True
|
| 614 |
if t in ("more", "view more", "view more properties"):
|
| 615 |
+
end = min(len(ids), shown + PAGE)
|
| 616 |
+
send_result_page(m, [U.get_listing(i) for i in ids], shown, end)
|
| 617 |
+
set_s(m, {**s, "shown": end, "screen": "results"})
|
| 618 |
return True
|
| 619 |
if t in ("back", "back to results"):
|
| 620 |
wa.send_text_message(m, story_results_text([U.get_listing(i) for i in ids], shown))
|
| 621 |
+
set_s(m, {**s, "detail": "", "detail_n": 0, "screen": "results"})
|
| 622 |
return True
|
| 623 |
+
if t in ("pass", "get rental pass", "rental pass", "payment done"):
|
| 624 |
buy_pass(m)
|
| 625 |
return True
|
| 626 |
+
if t in ("view property now", "continue browsing"):
|
| 627 |
+
lid = s.get("pending_unlock")
|
| 628 |
+
if t == "view property now" and lid:
|
| 629 |
+
return handle_browse(m, f"unlock {_num_of(s, lid)}", {**s, "screen": "results"})
|
| 630 |
+
wa.send_text_message(m, story_results_text([U.get_listing(i) for i in ids], shown))
|
| 631 |
+
set_s(m, {**s, "screen": "results"})
|
| 632 |
+
return True
|
| 633 |
+
if s.get("screen") == "pass_ready" and t in ("1", "2"):
|
| 634 |
+
return handle_browse(m, "view property now" if t == "1" else "continue browsing", s)
|
| 635 |
+
if t in ("ask a question", "back to property") and s.get("detail"):
|
| 636 |
+
if t == "back to property":
|
| 637 |
+
show_detail(m, s["detail"], s.get("detail_n", 0))
|
| 638 |
+
else:
|
| 639 |
+
wa.send_text_message(m, "Type your question. Our team will help with this property.")
|
| 640 |
+
set_s(m, {**s, "screen": "question"})
|
| 641 |
+
return True
|
| 642 |
+
if s.get("screen") == "question":
|
| 643 |
+
if U.db:
|
| 644 |
+
U.db.collection("rentnow_support").add({"phone": m, "listing_id": s["detail"], "issue": text, "ts": U._now(), "status": "open"})
|
| 645 |
+
else:
|
| 646 |
+
U._mem[f"question:{uuid.uuid4().hex}"] = {"phone": m, "listing_id": s["detail"], "issue": text}
|
| 647 |
+
wa.send_text_message(m, "Question received. Our team will reply here. Reply BACK TO PROPERTY to return.")
|
| 648 |
+
set_s(m, {**s, "screen": "full"})
|
| 649 |
+
return True
|
| 650 |
+
# Resolve numbered text-menu actions within the current property screen.
|
| 651 |
det_id = s.get("detail", "")
|
| 652 |
if "request viewing" in t and det_id:
|
| 653 |
if not U.is_unlocked(m, det_id):
|
|
|
|
| 719 |
n = int(t)
|
| 720 |
if 1 <= n <= len(ids):
|
| 721 |
show_detail(m, ids[n - 1], n)
|
|
|
|
| 722 |
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
return False
|
| 724 |
|
| 725 |
|
|
|
|
| 752 |
low = text.strip().lower()
|
| 753 |
logger.info(f"STEP route from={mobile} session={(s or {}).get('menu')}/{str((s or {}).get('step'))}")
|
| 754 |
|
| 755 |
+
if low in ("menu", "start"):
|
| 756 |
+
send_welcome(mobile)
|
| 757 |
+
return
|
| 758 |
+
|
| 759 |
if GREET.match(text.strip()) and (not s or s.get("menu") in ("main", "post_reg")):
|
| 760 |
logger.info(f"STEP welcome from={mobile}")
|
| 761 |
send_welcome(mobile)
|
|
|
|
| 811 |
return
|
| 812 |
if menu == "await_pay":
|
| 813 |
logger.info(f"STEP pay-confirm from={mobile} kind={s.get('kind')} low={low!r}")
|
| 814 |
+
if low in ("1", "payment done", "i have paid", "paid", "confirm", "pay", "pay with ecocash (demo)"):
|
| 815 |
pay = U.confirm_payment_spoof(s["payment_id"], text)
|
| 816 |
logger.info(f"STEP pay-status from={mobile} pay={(pay or {}).get('id')} status={(pay or {}).get('status')}")
|
| 817 |
if not pay or pay.get("status") != "confirmed":
|
|
|
|
| 820 |
if s.get("kind") == "rental_pass":
|
| 821 |
p = U.issue_pass(mobile, pay["id"])
|
| 822 |
left = p["credits_total"] - p["credits_used"]
|
| 823 |
+
wa.send_text_message(mobile, f"Payment successful (demo). Your $10 Rental Pass is active.\n{left} property unlocks available, valid for 14 days.")
|
| 824 |
+
set_s(mobile, {**s, "menu": "browse", "screen": "pass_ready"})
|
| 825 |
+
if s.get("pending_unlock"):
|
| 826 |
+
wa.send_buttons_as_text(mobile, "View the property you selected, or keep browsing free.", ["View property now", "Continue browsing"])
|
| 827 |
+
else:
|
| 828 |
+
wa.send_text_message(mobile, "Reply CONTINUE BROWSING or NEW to find a property.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 829 |
else:
|
| 830 |
v = U.create_viewing(mobile, s.get("listing_id", ""), pay["id"])
|
| 831 |
+
item = U.get_listing(s.get("listing_id", "")) or {}
|
| 832 |
+
set_s(mobile, {**s, "menu": "browse", "screen": "full"})
|
| 833 |
+
wa.send_text_message(mobile, f"Viewing request paid (demo) [{v['id']}]. Our team will send your request to the provider and confirm the appointment date and time here.\n"
|
| 834 |
+
f"Provider: {item.get('provider_name') or 'Contact RentNow'} {item.get('provider_phone') or ''}\nReply BACK TO PROPERTY or ASK A QUESTION.")
|
| 835 |
return
|
| 836 |
+
if low in ("2", "cancel", "continue browsing"):
|
| 837 |
+
set_s(mobile, {**s, "menu": "browse", "screen": "results"})
|
| 838 |
+
wa.send_text_message(mobile, "Payment cancelled. You can keep browsing free.")
|
| 839 |
+
wa.send_text_message(mobile, story_results_text([U.get_listing(i) for i in s.get("results", [])], s.get("shown", PAGE)))
|
| 840 |
return
|
| 841 |
+
wa.send_text_message(mobile, "Reply 1 to simulate payment or 2 to continue browsing free.")
|
| 842 |
+
return
|
| 843 |
|
| 844 |
# keyword shortcuts (no session)
|
| 845 |
logger.info(f"STEP shortcuts from={mobile} low={low!r}")
|
test_flows.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Conversation regressions. No credentials, network calls or real payments."""
|
| 2 |
+
import os
|
| 3 |
+
import unittest
|
| 4 |
+
from unittest.mock import patch
|
| 5 |
+
|
| 6 |
+
os.environ.pop('FIREBASE', None)
|
| 7 |
+
os.environ.pop('GOOGLE_API_KEY', None)
|
| 8 |
+
with patch('dotenv.load_dotenv'):
|
| 9 |
+
import main as bot
|
| 10 |
+
import utility as U
|
| 11 |
+
import whatsapp_client as wa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RentalFlows(unittest.TestCase):
|
| 15 |
+
def setUp(self):
|
| 16 |
+
self.phone = '+263770000099'
|
| 17 |
+
U.db = None
|
| 18 |
+
U._mem.clear()
|
| 19 |
+
self.messages = []
|
| 20 |
+
self.images = []
|
| 21 |
+
self.patches = [
|
| 22 |
+
patch.object(wa, 'send_text_message', side_effect=lambda m, text: self.messages.append(text) or True),
|
| 23 |
+
patch.object(wa, 'send_image_message', side_effect=lambda m, url, caption='': self.images.append((url, caption)) or True),
|
| 24 |
+
patch.object(wa, 'send_document_message', return_value=True),
|
| 25 |
+
patch.object(U, 'gemini_ack_search', return_value='Matching homes'),
|
| 26 |
+
patch.object(U, 'gemini_detect_intent', return_value={'intent': 'SEARCH', 'criteria': {}}),
|
| 27 |
+
]
|
| 28 |
+
bot.logger.disabled = True
|
| 29 |
+
for p in self.patches:
|
| 30 |
+
p.start()
|
| 31 |
+
self.addCleanup(p.stop)
|
| 32 |
+
|
| 33 |
+
def say(self, text):
|
| 34 |
+
bot._process_text_inner(text, self.phone)
|
| 35 |
+
return bot.get_s(self.phone)
|
| 36 |
+
|
| 37 |
+
def browse(self):
|
| 38 |
+
U.save_profile(self.phone, {'name': 'Test Renter'})
|
| 39 |
+
bot.show_results(self.phone, {})
|
| 40 |
+
return bot.get_s(self.phone)
|
| 41 |
+
|
| 42 |
+
def test_all_text_menu_options_visible(self):
|
| 43 |
+
wa.send_buttons_as_text(self.phone, 'Features', bot.FIND_AMENS)
|
| 44 |
+
self.assertIn('8. Other (type)', self.messages[-1])
|
| 45 |
+
|
| 46 |
+
def test_guided_any_choices_reach_summary(self):
|
| 47 |
+
self.say('menu')
|
| 48 |
+
self.say('1')
|
| 49 |
+
self.assertEqual(self.say('1')['step'], 'type')
|
| 50 |
+
self.assertEqual(self.say('2')['step'], 'suburb')
|
| 51 |
+
self.assertEqual(self.say('8')['step'], 'budget')
|
| 52 |
+
self.assertEqual(self.say('6')['step'], 'amen')
|
| 53 |
+
state = self.say('skip')
|
| 54 |
+
self.assertEqual(state['step'], 'summary')
|
| 55 |
+
self.assertEqual(state['data']['type'], 'cottage')
|
| 56 |
+
self.assertEqual(self.say('1')['menu'], 'browse')
|
| 57 |
+
|
| 58 |
+
def test_budget_range_preserves_lower_bound(self):
|
| 59 |
+
bot.start_find(self.phone, {'type': 'cottage', 'suburb': 'Avondale'})
|
| 60 |
+
self.say('2')
|
| 61 |
+
state = self.say('7')
|
| 62 |
+
self.assertEqual(state['data']['min_budget'], 200)
|
| 63 |
+
self.assertEqual(state['data']['max_budget'], 400)
|
| 64 |
+
|
| 65 |
+
def test_over_800_has_no_upper_bound(self):
|
| 66 |
+
bot.start_find(self.phone, {'type': 'house', 'suburb': 'Harare'})
|
| 67 |
+
self.assertEqual(self.say('5')['step'], 'amen')
|
| 68 |
+
state = self.say('7')
|
| 69 |
+
self.assertGreater(state['data']['min_budget'], 800)
|
| 70 |
+
self.assertEqual(state['data']['max_budget'], 0)
|
| 71 |
+
|
| 72 |
+
def test_compact_features(self):
|
| 73 |
+
self.assertEqual(bot._pick_multi('123', bot.FIND_AMENS), ['Borehole', 'Solar', 'Parking'])
|
| 74 |
+
self.assertEqual(bot._pick_multi('7', bot.FIND_AMENS), [])
|
| 75 |
+
|
| 76 |
+
def test_natural_input_from_feature_step(self):
|
| 77 |
+
bot.start_find(self.phone, {'type': 'cottage', 'suburb': 'Westgate', 'max_budget': 400})
|
| 78 |
+
state = self.say('1 bed cottage in Mount Pleasant with borehole solar parking under $400')
|
| 79 |
+
self.assertEqual(state['step'], 'summary')
|
| 80 |
+
self.assertEqual(state['data']['type'], 'cottage')
|
| 81 |
+
self.assertEqual(state['data']['suburb'], 'Mount Pleasant')
|
| 82 |
+
self.assertEqual(len(state['data']['amenities']), 3)
|
| 83 |
+
|
| 84 |
+
def test_edit_budget_returns_to_summary(self):
|
| 85 |
+
bot._finish_find(self.phone, {'type': 'cottage', 'suburb': 'Avondale', 'max_budget': 400, 'features_chosen': True})
|
| 86 |
+
self.say('2')
|
| 87 |
+
self.assertEqual(self.say('3')['step'], 'budget')
|
| 88 |
+
state = self.say('350')
|
| 89 |
+
self.assertEqual(state['step'], 'summary')
|
| 90 |
+
self.assertEqual(state['data']['max_budget'], 350)
|
| 91 |
+
|
| 92 |
+
def test_first_result_is_free_preview(self):
|
| 93 |
+
self.browse()
|
| 94 |
+
state = self.say('1')
|
| 95 |
+
self.assertEqual(state['screen'], 'preview')
|
| 96 |
+
self.assertFalse(any(k.startswith('pay:') for k in U._mem))
|
| 97 |
+
self.assertTrue(self.images)
|
| 98 |
+
|
| 99 |
+
def test_payment_then_selected_unlock_and_repeat_preview(self):
|
| 100 |
+
original = self.browse()
|
| 101 |
+
lid = original['results'][0]
|
| 102 |
+
self.say('1')
|
| 103 |
+
state = self.say('1')
|
| 104 |
+
self.assertEqual(state['menu'], 'await_pay')
|
| 105 |
+
self.assertEqual(state['results'], original['results'])
|
| 106 |
+
state = self.say('1')
|
| 107 |
+
self.assertEqual(state['screen'], 'pass_ready')
|
| 108 |
+
self.assertEqual(U.active_pass(self.phone)['credits_used'], 0)
|
| 109 |
+
state = self.say('1')
|
| 110 |
+
self.assertEqual(state['screen'], 'full')
|
| 111 |
+
self.assertTrue(U.is_unlocked(self.phone, lid))
|
| 112 |
+
self.assertEqual(U.active_pass(self.phone)['credits_used'], 1)
|
| 113 |
+
self.say('back to results')
|
| 114 |
+
self.say('1')
|
| 115 |
+
self.assertEqual(U.active_pass(self.phone)['credits_used'], 1)
|
| 116 |
+
|
| 117 |
+
def test_account_capture_retains_selected_property(self):
|
| 118 |
+
bot.show_results(self.phone, {})
|
| 119 |
+
original = bot.get_s(self.phone)
|
| 120 |
+
self.assertEqual(self.say('unlock 2')['menu'], 'need_name')
|
| 121 |
+
state = self.say('Test Renter')
|
| 122 |
+
self.assertEqual(state['menu'], 'await_pay')
|
| 123 |
+
self.assertEqual(state['results'], original['results'])
|
| 124 |
+
self.assertEqual(state['pending_unlock'], original['results'][1])
|
| 125 |
+
|
| 126 |
+
def test_cancel_preserves_results_without_pass(self):
|
| 127 |
+
original = self.browse()
|
| 128 |
+
self.say('unlock 2')
|
| 129 |
+
state = self.say('2')
|
| 130 |
+
self.assertEqual(state['menu'], 'browse')
|
| 131 |
+
self.assertEqual(state['results'], original['results'])
|
| 132 |
+
self.assertIsNone(U.active_pass(self.phone))
|
| 133 |
+
|
| 134 |
+
def test_continue_after_pass_does_not_consume_credit(self):
|
| 135 |
+
self.browse()
|
| 136 |
+
self.say('unlock 2')
|
| 137 |
+
self.say('1')
|
| 138 |
+
state = self.say('2')
|
| 139 |
+
self.assertEqual(state['screen'], 'results')
|
| 140 |
+
self.assertEqual(U.active_pass(self.phone)['credits_used'], 0)
|
| 141 |
+
|
| 142 |
+
def test_full_gallery_and_viewing_request(self):
|
| 143 |
+
original = self.browse()
|
| 144 |
+
lid = original['results'][0]
|
| 145 |
+
self.say('unlock 1')
|
| 146 |
+
self.say('1')
|
| 147 |
+
self.say('1')
|
| 148 |
+
state = self.say('1')
|
| 149 |
+
self.assertEqual(state['kind'], 'viewing')
|
| 150 |
+
self.assertEqual(U._mem['pay:' + state['payment_id']]['amount_cents'], 500)
|
| 151 |
+
self.assertIn('$2 goes to the landlord', '\n'.join(self.messages))
|
| 152 |
+
state = self.say('1')
|
| 153 |
+
self.assertEqual(state['menu'], 'browse')
|
| 154 |
+
viewings = [v for k, v in U._mem.items() if k.startswith('view:')]
|
| 155 |
+
self.assertEqual(len(viewings), 1)
|
| 156 |
+
self.assertEqual(viewings[0]['status'], 'paid_appointment_pending')
|
| 157 |
+
self.assertEqual(viewings[0]['listing_id'], lid)
|
| 158 |
+
|
| 159 |
+
def test_locked_property_cannot_buy_viewing(self):
|
| 160 |
+
self.browse()
|
| 161 |
+
self.say('1')
|
| 162 |
+
self.say('request viewing')
|
| 163 |
+
self.assertFalse(any(k.startswith('pay:') for k in U._mem))
|
| 164 |
+
|
| 165 |
+
def test_more_handles_listing_without_ranking(self):
|
| 166 |
+
self.browse()
|
| 167 |
+
state = self.say('more')
|
| 168 |
+
self.assertEqual(state['shown'], 6)
|
| 169 |
+
self.say('back')
|
| 170 |
+
self.assertIn('NEW', self.messages[-1])
|
| 171 |
+
|
| 172 |
+
def test_constraints_filter_all_required_features_and_minimum(self):
|
| 173 |
+
fixture = [dict(id='a', type='cottage', suburb='Mt Pleasant', rent_cents=35000, amenities=['solar', 'parking']),
|
| 174 |
+
dict(id='b', type='cottage', suburb='Mount Pleasant', rent_cents=15000, amenities=['solar']),
|
| 175 |
+
dict(id='c', type='cottage', suburb='Mount Pleasant', rent_cents=85000, amenities=['solar', 'parking'])]
|
| 176 |
+
with patch.object(U, 'get_listings', return_value=fixture):
|
| 177 |
+
rows = U.search_listings({'suburb': 'Mount Pleasant', 'min_budget': 200, 'max_budget': 400, 'amenities': ['solar', 'parking']})
|
| 178 |
+
self.assertEqual([r['id'] for r in rows], ['a'])
|
| 179 |
+
|
| 180 |
+
def test_unknown_suburb_and_other_features(self):
|
| 181 |
+
bot.start_find(self.phone, {'type': 'cottage'})
|
| 182 |
+
self.say('7')
|
| 183 |
+
self.say('Chishawasha')
|
| 184 |
+
self.say('400')
|
| 185 |
+
self.say('8')
|
| 186 |
+
state = self.say('swimming pool')
|
| 187 |
+
self.assertEqual(state['step'], 'summary')
|
| 188 |
+
self.assertEqual(state['data']['amenities'], ['swimming pool'])
|
| 189 |
+
|
| 190 |
+
def test_pass_expires_and_exhausts(self):
|
| 191 |
+
from datetime import datetime, timezone, timedelta
|
| 192 |
+
p = U.issue_pass(self.phone, 'test-payment')
|
| 193 |
+
p['expires_at'] = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
| 194 |
+
self.assertIsNone(U.active_pass(self.phone))
|
| 195 |
+
p['expires_at'] = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()
|
| 196 |
+
for lid in ['a', 'b', 'c']:
|
| 197 |
+
self.assertTrue(U.consume_unlock(self.phone, lid)[0])
|
| 198 |
+
self.assertIsNone(U.active_pass(self.phone))
|
| 199 |
+
self.assertFalse(U.consume_unlock(self.phone, 'd')[0])
|
| 200 |
+
self.assertTrue(U.consume_unlock(self.phone, 'a')[0])
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
if __name__ == '__main__':
|
| 204 |
+
unittest.main()
|
utility.py
CHANGED
|
@@ -79,7 +79,7 @@ VIEWING_FEE_CENTS = 500 # $5
|
|
| 79 |
LANDLORD_SHARE_CENTS = 200 # $2
|
| 80 |
PLATFORM_SHARE_CENTS = 300 # $3
|
| 81 |
|
| 82 |
-
PROPERTY_TYPES = ["single room", "cottage", "1 bedroom", "2 bedroom"]
|
| 83 |
AMENITIES = ["solar", "borehole", "tiled floors", "fitted kitchen", "parking",
|
| 84 |
"security", "water", "electricity", "furnished", "wifi"]
|
| 85 |
CITIES = ["Harare", "Bulawayo", "Mutare", "Gweru", "Victoria Falls"]
|
|
@@ -355,6 +355,8 @@ def admin_create_listing(data: Dict, reviewer: str = "admin") -> Dict:
|
|
| 355 |
else [a.strip() for a in (data.get("amenities") or "").split(",") if a.strip()],
|
| 356 |
"media": data.get("media", []) if isinstance(data.get("media"), list)
|
| 357 |
else [u.strip() for u in (data.get("media") or "").split(",") if u.strip()],
|
|
|
|
|
|
|
| 358 |
"status": "active", "verification": "verified",
|
| 359 |
"created_by": reviewer, "created_at": _now(), "updated_at": _now(),
|
| 360 |
}
|
|
@@ -396,7 +398,7 @@ def match_score(item: Dict, crit: Dict) -> Tuple[int, List[str]]:
|
|
| 396 |
if want_type and want_type in (item.get("type") or "").lower():
|
| 397 |
score += 30
|
| 398 |
reasons.append("correct type")
|
| 399 |
-
suburb = (crit.get("suburb") or "").lower()
|
| 400 |
if suburb and suburb in (item.get("suburb") or "").lower():
|
| 401 |
score += 25
|
| 402 |
reasons.append("correct suburb")
|
|
@@ -423,19 +425,26 @@ def match_score(item: Dict, crit: Dict) -> Tuple[int, List[str]]:
|
|
| 423 |
def search_listings(crit: Dict, limit: int = 5) -> List[Dict]:
|
| 424 |
items = get_listings(active_only=True)
|
| 425 |
want_type = (crit.get("type") or "").lower()
|
| 426 |
-
suburb = (crit.get("suburb") or "").lower()
|
| 427 |
try:
|
| 428 |
max_b = float(crit.get("max_budget") or 0)
|
| 429 |
except Exception:
|
| 430 |
max_b = 0
|
|
|
|
| 431 |
ranked = []
|
| 432 |
for it in items:
|
| 433 |
if want_type and want_type not in (it.get("type") or "").lower():
|
| 434 |
continue
|
| 435 |
-
if suburb and suburb not in (it.get("suburb") or "").lower():
|
| 436 |
continue
|
| 437 |
if max_b and (it.get("rent_cents") or 0) / 100 > max_b:
|
| 438 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
s, reasons = match_score(it, crit)
|
| 440 |
ranked.append({**it, "_score": s, "_reasons": reasons})
|
| 441 |
ranked.sort(key=lambda x: (-x["_score"], x.get("rent_cents", 0)))
|
|
@@ -451,7 +460,10 @@ def format_preview(item: Dict, idx: int = 0, score: Optional[int] = None,
|
|
| 451 |
f" {', '.join(item.get('amenities', [])[:5])}"]
|
| 452 |
if score is not None:
|
| 453 |
lines.append(f" _{score}% match — {', '.join((reasons or [])[:4])}_")
|
| 454 |
-
lines.append("
|
|
|
|
|
|
|
|
|
|
| 455 |
return "\n".join(lines)
|
| 456 |
|
| 457 |
|
|
@@ -463,7 +475,10 @@ def format_full(item: Dict) -> str:
|
|
| 463 |
f"{item.get('description')}",
|
| 464 |
f"Amenities: {', '.join(item.get('amenities', []))}",
|
| 465 |
f"Verified: {'yes' if item.get('verification') == 'verified' else 'pending'}",
|
| 466 |
-
f"Photos: {len(item.get('media', []))} unlocked"
|
|
|
|
|
|
|
|
|
|
| 467 |
return "\n".join(lines)
|
| 468 |
|
| 469 |
|
|
@@ -516,8 +531,7 @@ def ecocash_instructions(pay: Dict) -> str:
|
|
| 516 |
f"EcoCash (spoofed MVP):\n"
|
| 517 |
f"Amount: *{cents_fmt(pay['amount_cents'])}*\n"
|
| 518 |
f"Reference: *{pay['provider_ref']}*\n"
|
| 519 |
-
|
| 520 |
-
f"_Demo mode: tap 1 (I have paid). Wiring comes later._"
|
| 521 |
)
|
| 522 |
|
| 523 |
|
|
@@ -895,18 +909,20 @@ def parse_budget(text: str) -> Tuple[Optional[float], Optional[float]]:
|
|
| 895 |
|
| 896 |
SUBURBS = ["westgate", "avondale", "borrowdale", "mbare", "kuwadzana", "glen view",
|
| 897 |
"hatcliffe", "epworth", "ruwa", "chitungwiza", "bulawayo", "cbd", "town",
|
| 898 |
-
"greendale", "mt pleasant", "chisipite", "highlands", "southerton"]
|
| 899 |
|
| 900 |
TYPE_KEYS = [
|
| 901 |
("2 bedroom", ["2 bedroom", "2 bedrooms", "2 bed", "two bedroom", "2bed"]),
|
| 902 |
("1 bedroom", ["1 bedroom", "1 bedrooms", "1 bed", "one bedroom", "1bed"]),
|
| 903 |
("cottage", ["cottage", "cottages"]),
|
|
|
|
|
|
|
| 904 |
("single room", ["single room", "single rooms", "room", "rooms", "bedsitter"]),
|
| 905 |
]
|
| 906 |
|
| 907 |
AMEN_KEYS = ["solar", "borehole", "parking", "furnished", "walled", "tiled",
|
| 908 |
"secure", "security", "water", "electricity", "wifi", "fitted kitchen",
|
| 909 |
-
"pet friendly", "pets"]
|
| 910 |
|
| 911 |
|
| 912 |
def parse_search(text: str) -> Dict:
|
|
@@ -918,7 +934,8 @@ def parse_search(text: str) -> Dict:
|
|
| 918 |
t = (text or "").lower()
|
| 919 |
crit: Dict = {"type": "", "suburb": "", "max_budget": 0, "amenities": []}
|
| 920 |
|
| 921 |
-
|
|
|
|
| 922 |
if any(k in t for k in keys):
|
| 923 |
crit["type"] = canon
|
| 924 |
break
|
|
@@ -940,6 +957,7 @@ def parse_search(text: str) -> Dict:
|
|
| 940 |
else:
|
| 941 |
rng = re.search(r"(\d[\d,]*)\s*[-\u2013]\s*(\d[\d,]*)", t)
|
| 942 |
if rng:
|
|
|
|
| 943 |
crit["max_budget"] = float(rng.group(2).replace(",", ""))
|
| 944 |
else:
|
| 945 |
nums = re.findall(r"\$\s*(\d[\d,]*)", t)
|
|
|
|
| 79 |
LANDLORD_SHARE_CENTS = 200 # $2
|
| 80 |
PLATFORM_SHARE_CENTS = 300 # $3
|
| 81 |
|
| 82 |
+
PROPERTY_TYPES = ["single room", "cottage", "1 bedroom", "2 bedroom", "house"]
|
| 83 |
AMENITIES = ["solar", "borehole", "tiled floors", "fitted kitchen", "parking",
|
| 84 |
"security", "water", "electricity", "furnished", "wifi"]
|
| 85 |
CITIES = ["Harare", "Bulawayo", "Mutare", "Gweru", "Victoria Falls"]
|
|
|
|
| 355 |
else [a.strip() for a in (data.get("amenities") or "").split(",") if a.strip()],
|
| 356 |
"media": data.get("media", []) if isinstance(data.get("media"), list)
|
| 357 |
else [u.strip() for u in (data.get("media") or "").split(",") if u.strip()],
|
| 358 |
+
"video_url": data.get("video_url", ""),
|
| 359 |
+
"rental_requirements": data.get("rental_requirements", ""),
|
| 360 |
"status": "active", "verification": "verified",
|
| 361 |
"created_by": reviewer, "created_at": _now(), "updated_at": _now(),
|
| 362 |
}
|
|
|
|
| 398 |
if want_type and want_type in (item.get("type") or "").lower():
|
| 399 |
score += 30
|
| 400 |
reasons.append("correct type")
|
| 401 |
+
suburb = (crit.get("suburb") or "").lower().replace("mt pleasant", "mount pleasant")
|
| 402 |
if suburb and suburb in (item.get("suburb") or "").lower():
|
| 403 |
score += 25
|
| 404 |
reasons.append("correct suburb")
|
|
|
|
| 425 |
def search_listings(crit: Dict, limit: int = 5) -> List[Dict]:
|
| 426 |
items = get_listings(active_only=True)
|
| 427 |
want_type = (crit.get("type") or "").lower()
|
| 428 |
+
suburb = (crit.get("suburb") or "").lower().replace("mt pleasant", "mount pleasant")
|
| 429 |
try:
|
| 430 |
max_b = float(crit.get("max_budget") or 0)
|
| 431 |
except Exception:
|
| 432 |
max_b = 0
|
| 433 |
+
min_b = float(crit.get("min_budget") or 0)
|
| 434 |
ranked = []
|
| 435 |
for it in items:
|
| 436 |
if want_type and want_type not in (it.get("type") or "").lower():
|
| 437 |
continue
|
| 438 |
+
if suburb and suburb not in (it.get("suburb") or "").lower().replace("mt pleasant", "mount pleasant"):
|
| 439 |
continue
|
| 440 |
if max_b and (it.get("rent_cents") or 0) / 100 > max_b:
|
| 441 |
continue
|
| 442 |
+
if min_b and (it.get("rent_cents") or 0) / 100 < min_b:
|
| 443 |
+
continue
|
| 444 |
+
wants = [a.lower() for a in crit.get("amenities", [])]
|
| 445 |
+
haves = [a.lower() for a in it.get("amenities", [])]
|
| 446 |
+
if any(not any(a in h for h in haves) for a in wants):
|
| 447 |
+
continue
|
| 448 |
s, reasons = match_score(it, crit)
|
| 449 |
ranked.append({**it, "_score": s, "_reasons": reasons})
|
| 450 |
ranked.sort(key=lambda x: (-x["_score"], x.get("rent_cents", 0)))
|
|
|
|
| 460 |
f" {', '.join(item.get('amenities', [])[:5])}"]
|
| 461 |
if score is not None:
|
| 462 |
lines.append(f" _{score}% match — {', '.join((reasons or [])[:4])}_")
|
| 463 |
+
lines.append(" Property: " + ("Verified" if item.get("verification") == "verified" else "Verification pending"))
|
| 464 |
+
provider = get_profile(item.get("provider_phone", ""))
|
| 465 |
+
lines.append(" Provider: " + ("Verified" if (provider.get("landlord") or {}).get("verification") == "verified" else "Verification pending"))
|
| 466 |
+
lines.append(" _Free preview. Full details available with a $10 Rental Pass._")
|
| 467 |
return "\n".join(lines)
|
| 468 |
|
| 469 |
|
|
|
|
| 475 |
f"{item.get('description')}",
|
| 476 |
f"Amenities: {', '.join(item.get('amenities', []))}",
|
| 477 |
f"Verified: {'yes' if item.get('verification') == 'verified' else 'pending'}",
|
| 478 |
+
f"Photos: {len(item.get('media', []))} unlocked",
|
| 479 |
+
f"Rental requirements: {item.get('rental_requirements') or item.get('requirements') or 'Ask RentNow for the provider requirements.'}",
|
| 480 |
+
f"Provider: {item.get('provider_name') or 'RentNow coordinates with the provider'}",
|
| 481 |
+
"Provider contact details are shared after the $5 viewing payment."]
|
| 482 |
return "\n".join(lines)
|
| 483 |
|
| 484 |
|
|
|
|
| 531 |
f"EcoCash (spoofed MVP):\n"
|
| 532 |
f"Amount: *{cents_fmt(pay['amount_cents'])}*\n"
|
| 533 |
f"Reference: *{pay['provider_ref']}*\n"
|
| 534 |
+
"Demo only. No money is charged. Reply 1 to simulate a successful EcoCash payment."
|
|
|
|
| 535 |
)
|
| 536 |
|
| 537 |
|
|
|
|
| 909 |
|
| 910 |
SUBURBS = ["westgate", "avondale", "borrowdale", "mbare", "kuwadzana", "glen view",
|
| 911 |
"hatcliffe", "epworth", "ruwa", "chitungwiza", "bulawayo", "cbd", "town",
|
| 912 |
+
"greendale", "mount pleasant", "mt pleasant", "chisipite", "highlands", "southerton"]
|
| 913 |
|
| 914 |
TYPE_KEYS = [
|
| 915 |
("2 bedroom", ["2 bedroom", "2 bedrooms", "2 bed", "two bedroom", "2bed"]),
|
| 916 |
("1 bedroom", ["1 bedroom", "1 bedrooms", "1 bed", "one bedroom", "1bed"]),
|
| 917 |
("cottage", ["cottage", "cottages"]),
|
| 918 |
+
("house", ["house", "townhouse"]),
|
| 919 |
+
("cottage", ["garden flat"]),
|
| 920 |
("single room", ["single room", "single rooms", "room", "rooms", "bedsitter"]),
|
| 921 |
]
|
| 922 |
|
| 923 |
AMEN_KEYS = ["solar", "borehole", "parking", "furnished", "walled", "tiled",
|
| 924 |
"secure", "security", "water", "electricity", "wifi", "fitted kitchen",
|
| 925 |
+
"pet friendly", "pets", "ensuite", "gated community"]
|
| 926 |
|
| 927 |
|
| 928 |
def parse_search(text: str) -> Dict:
|
|
|
|
| 934 |
t = (text or "").lower()
|
| 935 |
crit: Dict = {"type": "", "suburb": "", "max_budget": 0, "amenities": []}
|
| 936 |
|
| 937 |
+
t = t.replace("mt pleasant", "mount pleasant")
|
| 938 |
+
for canon, keys in sorted(TYPE_KEYS, key=lambda pair: pair[0] != "cottage"):
|
| 939 |
if any(k in t for k in keys):
|
| 940 |
crit["type"] = canon
|
| 941 |
break
|
|
|
|
| 957 |
else:
|
| 958 |
rng = re.search(r"(\d[\d,]*)\s*[-\u2013]\s*(\d[\d,]*)", t)
|
| 959 |
if rng:
|
| 960 |
+
crit["min_budget"] = float(rng.group(1).replace(",", ""))
|
| 961 |
crit["max_budget"] = float(rng.group(2).replace(",", ""))
|
| 962 |
else:
|
| 963 |
nums = re.findall(r"\$\s*(\d[\d,]*)", t)
|
whatsapp_client.py
CHANGED
|
@@ -152,7 +152,7 @@ def send_audio_message(recipient_id: str, audio_url: str) -> bool:
|
|
| 152 |
|
| 153 |
|
| 154 |
def send_buttons_as_text(recipient_id: str, body: str, options: list) -> bool:
|
| 155 |
-
safe = [str(o) for o in (options or []) if str(o).strip()]
|
| 156 |
lines = [body, ""]
|
| 157 |
for i, opt in enumerate(safe, 1):
|
| 158 |
lines.append(f"{i}. {opt}")
|
|
|
|
| 152 |
|
| 153 |
|
| 154 |
def send_buttons_as_text(recipient_id: str, body: str, options: list) -> bool:
|
| 155 |
+
safe = [str(o) for o in (options or []) if str(o).strip()]
|
| 156 |
lines = [body, ""]
|
| 157 |
for i, opt in enumerate(safe, 1):
|
| 158 |
lines.append(f"{i}. {opt}")
|