codeBOKER commited on
Commit
0467de1
·
1 Parent(s): d8fc5d5

feat: send trip cards as individual messages and resolve trip_id from replies

Browse files

- Send each trip as a separate WhatsApp message with trip_id metadata
- Resolve trip_id automatically when customer replies to a trip card
- Store phone_number on customer upsert
- Parse context_message_id and phone_number from inbound messages
- Add get_message_by_whatsapp_id to repository
- Translate driver booking notification to Arabic
- Increase conversation context window to 8 messages

app/ai/tool_schemas.py CHANGED
@@ -34,7 +34,9 @@ _SEARCH_TRIPS = {
34
  "name": "search_trips",
35
  "description": (
36
  "Search active car or bus trips. "
37
- "Use when the customer asks for travel options."
 
 
38
  ),
39
  "parameters": {
40
  "type": "object",
@@ -104,23 +106,25 @@ _CREATE_BOOKING_LEAD = {
104
  "name": "create_booking_lead",
105
  "description": (
106
  "Create a pending booking lead and notify the driver. "
107
- "This does not reserve seats or confirm payment."
 
 
108
  ),
109
  "parameters": {
110
  "type": "object",
111
  "properties": {
112
- "trip_id": {"type": "string", "description": "Selected trip ID."},
113
  "requested_seats": {
114
  "type": "integer",
115
  "minimum": 1,
116
- "description": "Number of seats requested by the customer.",
117
  },
118
  "notes": {
119
  "type": "string",
120
  "description": "Optional customer notes or pickup details.",
121
  },
122
  },
123
- "required": ["trip_id", "requested_seats"],
124
  "additionalProperties": False,
125
  },
126
  },
 
34
  "name": "search_trips",
35
  "description": (
36
  "Search active car or bus trips. "
37
+ "Use when the customer asks for travel options. "
38
+ "Each matching trip is sent as a separate WhatsApp message. "
39
+ "The customer can reply to a trip card to select it."
40
  ),
41
  "parameters": {
42
  "type": "object",
 
106
  "name": "create_booking_lead",
107
  "description": (
108
  "Create a pending booking lead and notify the driver. "
109
+ "This does not reserve seats or confirm payment. "
110
+ "If the customer replied to a trip card, trip_id is resolved automatically — "
111
+ "you can omit it and the system will detect which trip they meant."
112
  ),
113
  "parameters": {
114
  "type": "object",
115
  "properties": {
116
+ "trip_id": {"type": "string", "description": "Selected trip ID. Optional if the customer replied to a trip card message."},
117
  "requested_seats": {
118
  "type": "integer",
119
  "minimum": 1,
120
+ "description": "Number of seats requested. Defaults to 1 if not specified.",
121
  },
122
  "notes": {
123
  "type": "string",
124
  "description": "Optional customer notes or pickup details.",
125
  },
126
  },
127
+ "required": [],
128
  "additionalProperties": False,
129
  },
130
  },
app/database/supabase.py CHANGED
@@ -38,11 +38,13 @@ class SupabaseRepository:
38
  remote_jid: str,
39
  name: str | None = None,
40
  preferred_language: str | None = None,
 
41
  ) -> dict[str, Any]:
42
  payload = {
43
  "remoteJid": remote_jid,
44
  "name": name,
45
  "preferred_language": preferred_language,
 
46
  }
47
  payload = {key: value for key, value in payload.items() if value is not None}
48
  response = await (
@@ -200,6 +202,19 @@ class SupabaseRepository:
200
  prior = _response_data(prior_response) or []
201
  return list(reversed(prior)) + [current]
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  async def list_active_trips(self) -> list[dict[str, Any]]:
204
  query = (
205
  self.client.table("driver_trips")
 
38
  remote_jid: str,
39
  name: str | None = None,
40
  preferred_language: str | None = None,
41
+ phone_number: str | None = None,
42
  ) -> dict[str, Any]:
43
  payload = {
44
  "remoteJid": remote_jid,
45
  "name": name,
46
  "preferred_language": preferred_language,
47
+ "phone_number": phone_number,
48
  }
49
  payload = {key: value for key, value in payload.items() if value is not None}
50
  response = await (
 
202
  prior = _response_data(prior_response) or []
203
  return list(reversed(prior)) + [current]
204
 
205
+ async def get_message_by_whatsapp_id(
206
+ self,
207
+ whatsapp_message_id: str,
208
+ ) -> dict[str, Any] | None:
209
+ response = await (
210
+ self.client.table("messages")
211
+ .select("*")
212
+ .eq("whatsapp_message_id", whatsapp_message_id)
213
+ .maybe_single()
214
+ .execute()
215
+ )
216
+ return _response_data(response)
217
+
218
  async def list_active_trips(self) -> list[dict[str, Any]]:
219
  query = (
220
  self.client.table("driver_trips")
app/models/domain.py CHANGED
@@ -15,6 +15,8 @@ class WhatsAppInboundMessage:
15
  phone_number_id: str | None = None
16
  message_type: str = "text"
17
  interactive_reply_id: str | None = None
 
 
18
  raw: dict[str, Any] = field(default_factory=dict)
19
 
20
 
 
15
  phone_number_id: str | None = None
16
  message_type: str = "text"
17
  interactive_reply_id: str | None = None
18
+ context_message_id: str | None = None
19
+ phone_number: str | None = None
20
  raw: dict[str, Any] = field(default_factory=dict)
21
 
22
 
app/services/conversation_service.py CHANGED
@@ -78,24 +78,37 @@ class ConversationService:
78
  customer = await self.repository.upsert_customer(
79
  remote_jid=inbound.remoteJid,
80
  name=inbound.profile_name,
 
81
  )
82
 
 
 
 
 
 
 
 
83
  current_message = await self.repository.create_message(
84
  customer_id=str(customer["id"]),
85
  sender_type="customer",
86
  message=inbound.text,
87
  whatsapp_message_id=inbound.message_id,
88
- metadata={"whatsapp": inbound.raw, "timestamp": inbound.timestamp},
89
  )
90
 
91
  context = await self.repository.get_recent_context_messages(
92
  customer_id=str(customer["id"]),
93
  current_message_id=str(current_message["id"]),
94
- limit=4,
95
  )
96
 
97
  user_mode = _resolve_user_mode(customer)
98
- registry = self._tool_registry(customer, remoteJid=inbound.remoteJid, user_mode=user_mode)
 
 
 
 
 
99
  reply = await self.ai.generate_reply(
100
  messages=self._ai_messages(context, user_mode=user_mode),
101
  tools=get_tool_schemas(user_mode),
@@ -188,11 +201,12 @@ class ConversationService:
188
  customer,
189
  remoteJid=inbound.remoteJid,
190
  user_mode=user_mode,
 
191
  )
192
  context = await self.repository.get_recent_context_messages(
193
  customer_id=str(customer["id"]),
194
  current_message_id=str(current_message["id"]),
195
- limit=4,
196
  )
197
  messages = self._ai_messages(context, user_mode=user_mode)
198
  messages.append({"role": "system", "content": system_note})
@@ -233,6 +247,7 @@ class ConversationService:
233
  *,
234
  remoteJid: str,
235
  user_mode: UserMode,
 
236
  ) -> ToolRegistry:
237
  handlers = FalsaToolHandlers(
238
  repository=self.repository,
@@ -241,6 +256,7 @@ class ConversationService:
241
  customer=customer,
242
  remoteJid=remoteJid,
243
  embedding_model=self.settings.jina_embedding_model,
 
244
  )
245
  registry = ToolRegistry()
246
  for tool_name in _TOOLS_BY_MODE[user_mode]:
 
78
  customer = await self.repository.upsert_customer(
79
  remote_jid=inbound.remoteJid,
80
  name=inbound.profile_name,
81
+ phone_number=inbound.phone_number,
82
  )
83
 
84
+ metadata: dict[str, Any] = {
85
+ "whatsapp": inbound.raw,
86
+ "timestamp": inbound.timestamp,
87
+ }
88
+ if inbound.context_message_id:
89
+ metadata["context_message_id"] = inbound.context_message_id
90
+
91
  current_message = await self.repository.create_message(
92
  customer_id=str(customer["id"]),
93
  sender_type="customer",
94
  message=inbound.text,
95
  whatsapp_message_id=inbound.message_id,
96
+ metadata=metadata,
97
  )
98
 
99
  context = await self.repository.get_recent_context_messages(
100
  customer_id=str(customer["id"]),
101
  current_message_id=str(current_message["id"]),
102
+ limit=8,
103
  )
104
 
105
  user_mode = _resolve_user_mode(customer)
106
+ registry = self._tool_registry(
107
+ customer,
108
+ remoteJid=inbound.remoteJid,
109
+ user_mode=user_mode,
110
+ current_message=current_message,
111
+ )
112
  reply = await self.ai.generate_reply(
113
  messages=self._ai_messages(context, user_mode=user_mode),
114
  tools=get_tool_schemas(user_mode),
 
201
  customer,
202
  remoteJid=inbound.remoteJid,
203
  user_mode=user_mode,
204
+ current_message=current_message,
205
  )
206
  context = await self.repository.get_recent_context_messages(
207
  customer_id=str(customer["id"]),
208
  current_message_id=str(current_message["id"]),
209
+ limit=8,
210
  )
211
  messages = self._ai_messages(context, user_mode=user_mode)
212
  messages.append({"role": "system", "content": system_note})
 
247
  *,
248
  remoteJid: str,
249
  user_mode: UserMode,
250
+ current_message: dict[str, Any] | None = None,
251
  ) -> ToolRegistry:
252
  handlers = FalsaToolHandlers(
253
  repository=self.repository,
 
256
  customer=customer,
257
  remoteJid=remoteJid,
258
  embedding_model=self.settings.jina_embedding_model,
259
+ current_message=current_message,
260
  )
261
  registry = ToolRegistry()
262
  for tool_name in _TOOLS_BY_MODE[user_mode]:
app/tools/handlers.py CHANGED
@@ -1,6 +1,9 @@
 
1
  from decimal import Decimal, InvalidOperation
2
  from typing import Any
3
 
 
 
4
  from app.database.supabase import SupabaseRepository
5
  from app.models.domain import ToolResult
6
  from app.services.embedding_service import JinaEmbeddingService
@@ -15,7 +18,7 @@ from app.utils.departure import (
15
  trip_satisfies_departure_request,
16
  )
17
  from app.whatsapp.client import WhatsAppClient, WhatsAppClientError
18
- from app.whatsapp.trip_selection import build_trip_selection_list, build_trip_selection_text
19
 
20
 
21
  class FalsaToolHandlers:
@@ -28,6 +31,7 @@ class FalsaToolHandlers:
28
  customer: dict[str, Any],
29
  remoteJid: str,
30
  embedding_model: str,
 
31
  ) -> None:
32
  self.repository = repository
33
  self.embeddings = embeddings
@@ -35,6 +39,20 @@ class FalsaToolHandlers:
35
  self.customer = customer
36
  self.remoteJid = remoteJid
37
  self.embedding_model = embedding_model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  async def about_falsa(self, arguments: dict[str, Any]) -> ToolResult:
40
  query = str(arguments.get("query") or "").strip()
@@ -143,16 +161,38 @@ class FalsaToolHandlers:
143
  )
144
  ])
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  return ToolResult(
147
  ok=True,
148
  data={
149
- "matches": filtered[:5],
150
- "count": len(filtered[:5]),
151
  "alternate_alert": alternate_alert,
 
152
  "note": (
153
  "No active matching trips were found."
154
- if not filtered
155
- else _trip_search_note(alternate_alert)
 
156
  ),
157
  },
158
  )
@@ -163,7 +203,9 @@ class FalsaToolHandlers:
163
  notes = _optional_string(arguments.get("notes"))
164
 
165
  if not trip_id:
166
- return ToolResult(ok=False, data={}, error="trip_id is required")
 
 
167
  if requested_seats < 1:
168
  return ToolResult(ok=False, data={}, error="requested_seats must be at least 1")
169
 
@@ -186,6 +228,7 @@ class FalsaToolHandlers:
186
  notes=notes,
187
  )
188
 
 
189
  notification_status = "sent"
190
  notification_error = None
191
  try:
@@ -194,6 +237,7 @@ class FalsaToolHandlers:
194
  driver_remote_jid = driver_customer.get("remoteJid") or driver_record.get("remoteJid")
195
  if not driver_remote_jid:
196
  raise WhatsAppClientError("Driver remoteJid is missing")
 
197
  await self.whatsapp.send_text(
198
  driver_remote_jid,
199
  _driver_notification_text(
@@ -220,6 +264,7 @@ class FalsaToolHandlers:
220
  "status": "pending",
221
  "driver_notification_status": notification_status,
222
  "driver_notification_error": notification_error,
 
223
  "message": "Booking lead created. Seats are not reserved until confirmed.",
224
  },
225
  )
@@ -1033,12 +1078,11 @@ def _driver_notification_text(
1033
  notes: str | None,
1034
  ) -> str:
1035
  return (
1036
- "New FALSA booking lead\n"
1037
- f"Customer: {customer.get('name') or customer.get('remoteJid')}\n"
1038
- f"Remote JID: {customer.get('remoteJid')}\n"
1039
- f"Trip: {trip.get('departure')} -> {trip.get('destination')}\n"
1040
- f"Departure: {trip_departure_date(trip)} {trip_departure_bucket(trip)}\n"
1041
- f"Seats requested: {requested_seats}\n"
1042
- f"Notes: {notes or '-'}\n"
1043
- "Status: pending confirmation"
1044
  )
 
1
+ import logging
2
  from decimal import Decimal, InvalidOperation
3
  from typing import Any
4
 
5
+ logger = logging.getLogger(__name__)
6
+
7
  from app.database.supabase import SupabaseRepository
8
  from app.models.domain import ToolResult
9
  from app.services.embedding_service import JinaEmbeddingService
 
18
  trip_satisfies_departure_request,
19
  )
20
  from app.whatsapp.client import WhatsAppClient, WhatsAppClientError
21
+ from app.whatsapp.trip_selection import build_trip_selection_text, format_trip_card
22
 
23
 
24
  class FalsaToolHandlers:
 
31
  customer: dict[str, Any],
32
  remoteJid: str,
33
  embedding_model: str,
34
+ current_message: dict[str, Any] | None = None,
35
  ) -> None:
36
  self.repository = repository
37
  self.embeddings = embeddings
 
39
  self.customer = customer
40
  self.remoteJid = remoteJid
41
  self.embedding_model = embedding_model
42
+ self.current_message = current_message
43
+
44
+ async def _resolve_trip_id_from_reply(self) -> str | None:
45
+ if not self.current_message:
46
+ return None
47
+ metadata = self.current_message.get("metadata") or {}
48
+ context_message_id = metadata.get("context_message_id")
49
+ if not context_message_id:
50
+ return None
51
+ original = await self.repository.get_message_by_whatsapp_id(context_message_id)
52
+ if not original:
53
+ return None
54
+ original_meta = original.get("metadata") or {}
55
+ return original_meta.get("trip_id")
56
 
57
  async def about_falsa(self, arguments: dict[str, Any]) -> ToolResult:
58
  query = str(arguments.get("query") or "").strip()
 
161
  )
162
  ])
163
 
164
+ top_trips = filtered[:5]
165
+
166
+ if top_trips:
167
+ for trip_summary in top_trips:
168
+ trip_id = trip_summary["trip_id"]
169
+ trip = next((t for t in trips if (t.get("trip_id") or t.get("id")) == trip_id), {})
170
+ card = format_trip_card(trip)
171
+ try:
172
+ resp = await self.whatsapp.send_text(self.remoteJid, card)
173
+ wam_id = resp.get("messages", [{}])[0].get("id")
174
+ if wam_id:
175
+ await self.repository.create_message(
176
+ customer_id=str(self.customer["id"]),
177
+ sender_type="assistant",
178
+ message=card,
179
+ whatsapp_message_id=wam_id,
180
+ metadata={"trip_id": trip_id, "type": "trip_card"},
181
+ )
182
+ except WhatsAppClientError:
183
+ logger.warning("Failed to send trip card for trip %s", trip_id)
184
+
185
  return ToolResult(
186
  ok=True,
187
  data={
188
+ "count": len(top_trips),
 
189
  "alternate_alert": alternate_alert,
190
+ "sent_as_messages": bool(top_trips),
191
  "note": (
192
  "No active matching trips were found."
193
+ if not top_trips
194
+ else "Trips were sent as separate WhatsApp messages. "
195
+ "Ask the user to reply to a trip card to select it."
196
  ),
197
  },
198
  )
 
203
  notes = _optional_string(arguments.get("notes"))
204
 
205
  if not trip_id:
206
+ trip_id = await self._resolve_trip_id_from_reply()
207
+ if not trip_id:
208
+ return ToolResult(ok=False, data={}, error="trip_id is required. Ask the user to reply to a trip card message or provide the trip ID.")
209
  if requested_seats < 1:
210
  return ToolResult(ok=False, data={}, error="requested_seats must be at least 1")
211
 
 
228
  notes=notes,
229
  )
230
 
231
+ driver_phone: str | None = None
232
  notification_status = "sent"
233
  notification_error = None
234
  try:
 
237
  driver_remote_jid = driver_customer.get("remoteJid") or driver_record.get("remoteJid")
238
  if not driver_remote_jid:
239
  raise WhatsAppClientError("Driver remoteJid is missing")
240
+ driver_phone = driver_remote_jid.split("@")[0]
241
  await self.whatsapp.send_text(
242
  driver_remote_jid,
243
  _driver_notification_text(
 
264
  "status": "pending",
265
  "driver_notification_status": notification_status,
266
  "driver_notification_error": notification_error,
267
+ "driver_phone": driver_phone,
268
  "message": "Booking lead created. Seats are not reserved until confirmed.",
269
  },
270
  )
 
1078
  notes: str | None,
1079
  ) -> str:
1080
  return (
1081
+ "🔔 حجز جديد في فلسا\n"
1082
+ f"العميل: {customer.get('name') or 'عميل جديد'}\n"
1083
+ f"الرحلة: {trip.get('departure')} ← {trip.get('destination')}\n"
1084
+ f"التاريخ: {trip_departure_date(trip)} {trip_departure_bucket(trip)}\n"
1085
+ f"المقاعد المطلوبة: {requested_seats}\n"
1086
+ f"ملاحظات: {notes or 'لا يوجد'}\n"
1087
+ "الحالة: قيد الانتظار"
 
1088
  )
app/whatsapp/parser.py CHANGED
@@ -24,6 +24,8 @@ def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessa
24
 
25
  remoteJid = message.get("from")
26
  message_id = message.get("id")
 
 
27
 
28
  text = message.get("text", {}).get("body")
29
  if not remoteJid or not message_id or text is None:
@@ -42,6 +44,8 @@ def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessa
42
  profile_name=contacts_by_wa_id.get(remoteJid),
43
  phone_number_id=phone_number_id,
44
  message_type="text",
 
 
45
  raw=message,
46
  )
47
  )
@@ -67,6 +71,8 @@ def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessa
67
  phone_number_id=phone_number_id,
68
  message_type="interactive",
69
  interactive_reply_id=reply_id,
 
 
70
  raw=message,
71
  )
72
  )
 
24
 
25
  remoteJid = message.get("from")
26
  message_id = message.get("id")
27
+ phone_number = message.get("remote_jid_alt")
28
+ context_message_id = message.get("context", {}).get("id")
29
 
30
  text = message.get("text", {}).get("body")
31
  if not remoteJid or not message_id or text is None:
 
44
  profile_name=contacts_by_wa_id.get(remoteJid),
45
  phone_number_id=phone_number_id,
46
  message_type="text",
47
+ context_message_id=context_message_id,
48
+ phone_number=phone_number,
49
  raw=message,
50
  )
51
  )
 
71
  phone_number_id=phone_number_id,
72
  message_type="interactive",
73
  interactive_reply_id=reply_id,
74
+ context_message_id=context_message_id,
75
+ phone_number=phone_number,
76
  raw=message,
77
  )
78
  )
app/whatsapp/trip_selection.py CHANGED
@@ -76,6 +76,44 @@ def build_trip_selection_text(
76
  return "\n".join(lines)
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  def _trip_row_description(trip: dict[str, Any]) -> str:
80
  departure = str(trip.get("departure") or "")
81
  destination = str(trip.get("destination") or "")
 
76
  return "\n".join(lines)
77
 
78
 
79
+ def format_trip_card(trip: dict[str, Any]) -> str:
80
+ departure = str(trip.get("departure") or "")
81
+ destination = str(trip.get("destination") or "")
82
+ parsed_date = trip_departure_date(trip)
83
+ date_text = parsed_date.isoformat() if parsed_date else ""
84
+ bucket = trip_departure_bucket(trip)
85
+ bucket_text = _BUCKET_LABELS.get(bucket, "") if bucket else ""
86
+
87
+ driver = _first_dict_value(trip.get("drivers")) or {}
88
+ car = _first_dict_value(trip.get("driver_cars")) or {}
89
+ driver_name = driver.get("name") or trip.get("driver_name") or ""
90
+ car_type = car.get("car_type") or trip.get("car_type") or ""
91
+
92
+ available = trip.get("available_seats") or 0
93
+ total = trip.get("total_seats") or 0
94
+ price = trip.get("price") or ""
95
+
96
+ lines = [
97
+ "─" * 14,
98
+ f"من: {departure} ← إلى: {destination}",
99
+ f"التاريخ: {date_text} | الوقت: {bucket_text}",
100
+ f"المقاعد: {available} من {total} متاحة",
101
+ f"السعر: {price}" if price else "",
102
+ f"السيارة: {car_type}" if car_type else "",
103
+ f"السائق: {driver_name}" if driver_name else "",
104
+ "─" * 14,
105
+ ]
106
+ return "\n".join(line for line in lines if line)
107
+
108
+
109
+ def _first_dict_value(value: Any) -> dict[str, Any] | None:
110
+ if isinstance(value, list):
111
+ return value[0] if value else None
112
+ if isinstance(value, dict):
113
+ return value
114
+ return None
115
+
116
+
117
  def _trip_row_description(trip: dict[str, Any]) -> str:
118
  departure = str(trip.get("departure") or "")
119
  destination = str(trip.get("destination") or "")
prompts/system_passenger.md CHANGED
@@ -1,6 +1,7 @@
1
  - search_trips for travel options. Include vector_query_text (natural-language search phrase).
2
- - Show alternate_alert before listing trips.
3
- - create_booking_lead only after trip + seat count selected.
4
  - Ask short follow-up if details missing.
5
  - Booking leads are pending — seats not reserved.
 
6
  - To drive: switch_to_driver. If no account: name -> create_driver_account -> switch_to_driver. Never before account exists.
 
1
  - search_trips for travel options. Include vector_query_text (natural-language search phrase).
2
+ - When search_trips returns matches, trips are sent as separate WhatsApp messages. The user can reply to a trip card to select it — the system will detect which trip they meant automatically, without needing a trip_id or short_id.
3
+ - create_booking_lead only after trip + seat count selected. If the user replied to a trip card, trip_id is resolved automatically and you can omit it.
4
  - Ask short follow-up if details missing.
5
  - Booking leads are pending — seats not reserved.
6
+ - After successful create_booking_lead, tell the passenger the driver's phone number from driver_phone so they can contact the driver directly.
7
  - To drive: switch_to_driver. If no account: name -> create_driver_account -> switch_to_driver. Never before account exists.
supabase/migrations/202607010001_customer_phone_number.sql ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ alter table public.customers add column if not exists phone_number text;
2
+
3
+ create index if not exists idx_customers_phone_number
4
+ on public.customers(phone_number);
tests/conftest.py CHANGED
@@ -117,23 +117,30 @@ class FakeRepository:
117
  async def upsert_customer(
118
  self,
119
  *,
120
- remote_jid: str,
121
  name: str | None = None,
122
  preferred_language: str | None = None,
 
123
  ) -> dict[str, Any]:
124
- customer = self.customers_by_remote_jid.get(remote_jid)
 
 
 
125
  if customer is None:
126
  customer = {
127
  "id": f"cust-{len(self.customers_by_remote_jid) + 1}",
128
- "remoteJid": remote_jid,
129
  "name": name,
130
  "preferred_language": preferred_language,
 
131
  "user_mode": None,
132
  "session_data": {},
133
  }
134
- self.customers_by_remote_jid[remote_jid] = customer
135
  elif name:
136
  customer["name"] = name
 
 
137
  return customer
138
 
139
  async def update_customer_user_mode(
 
117
  async def upsert_customer(
118
  self,
119
  *,
120
+ remote_jid: str | None = None,
121
  name: str | None = None,
122
  preferred_language: str | None = None,
123
+ phone_number: str | None = None,
124
  ) -> dict[str, Any]:
125
+ jid = remote_jid or phone_number
126
+ if not jid:
127
+ raise ValueError("remote_jid or phone_number is required")
128
+ customer = self.customers_by_remote_jid.get(jid)
129
  if customer is None:
130
  customer = {
131
  "id": f"cust-{len(self.customers_by_remote_jid) + 1}",
132
+ "remoteJid": jid,
133
  "name": name,
134
  "preferred_language": preferred_language,
135
+ "phone_number": phone_number,
136
  "user_mode": None,
137
  "session_data": {},
138
  }
139
+ self.customers_by_remote_jid[jid] = customer
140
  elif name:
141
  customer["name"] = name
142
+ if phone_number:
143
+ customer["phone_number"] = phone_number
144
  return customer
145
 
146
  async def update_customer_user_mode(
tests/test_tools.py CHANGED
@@ -17,7 +17,7 @@ def make_handlers(
17
  embeddings=embeddings or FakeEmbeddings(),
18
  whatsapp=whatsapp or FakeWhatsApp(),
19
  customer=customer or {"id": "cust-1", "remoteJid": sender_phone},
20
- sender_phone=sender_phone,
21
  embedding_model="jina-embeddings-v5-text-small",
22
  )
23
 
 
17
  embeddings=embeddings or FakeEmbeddings(),
18
  whatsapp=whatsapp or FakeWhatsApp(),
19
  customer=customer or {"id": "cust-1", "remoteJid": sender_phone},
20
+ remoteJid=sender_phone,
21
  embedding_model="jina-embeddings-v5-text-small",
22
  )
23