codeBOKER commited on
Commit
1cd53ba
·
1 Parent(s): 087fc4c

support unregistered drivers from group messages and warm new user onboarding

Browse files
app/api/routes.py CHANGED
@@ -68,7 +68,14 @@ async def receive_whatsapp_webhook(
68
  payload: dict[str, Any] = await request.json()
69
  messages = parse_inbound_messages(payload)
70
  for inbound in messages:
71
- background_tasks.add_task(container.conversation.handle_inbound_message, inbound)
 
 
 
 
 
 
 
72
 
73
  return WebhookAcceptedResponse(messages=len(messages))
74
 
@@ -90,9 +97,12 @@ async def receive_whatsapp_webhook_debug(
90
  messages = parse_inbound_messages(payload)
91
  replies: list[str] = []
92
  for inbound in messages:
93
- reply = await container.conversation.handle_inbound_message(inbound)
94
- if reply is not None:
95
- replies.append(reply)
 
 
 
96
 
97
  return WebhookDebugResponse(messages=len(messages), replies=replies)
98
 
 
68
  payload: dict[str, Any] = await request.json()
69
  messages = parse_inbound_messages(payload)
70
  for inbound in messages:
71
+ if inbound.is_group:
72
+ background_tasks.add_task(
73
+ container.group_message.handle_group_message, inbound
74
+ )
75
+ else:
76
+ background_tasks.add_task(
77
+ container.conversation.handle_inbound_message, inbound
78
+ )
79
 
80
  return WebhookAcceptedResponse(messages=len(messages))
81
 
 
97
  messages = parse_inbound_messages(payload)
98
  replies: list[str] = []
99
  for inbound in messages:
100
+ if inbound.is_group:
101
+ await container.group_message.handle_group_message(inbound)
102
+ else:
103
+ reply = await container.conversation.handle_inbound_message(inbound)
104
+ if reply is not None:
105
+ replies.append(reply)
106
 
107
  return WebhookDebugResponse(messages=len(messages), replies=replies)
108
 
app/database/supabase.py CHANGED
@@ -39,6 +39,7 @@ class SupabaseRepository:
39
  name: str | None = None,
40
  preferred_language: str | None = None,
41
  phone_number: str | None = None,
 
42
  ) -> dict[str, Any]:
43
  if phone_number:
44
  phone_number = phone_number.split("@")[0]
@@ -47,6 +48,7 @@ class SupabaseRepository:
47
  "name": name,
48
  "preferred_language": preferred_language,
49
  "phone_number": phone_number,
 
50
  }
51
  payload = {key: value for key, value in payload.items() if value is not None}
52
  response = await (
@@ -57,6 +59,110 @@ class SupabaseRepository:
57
  data = _response_data(response)
58
  return data[0] if isinstance(data, list) else data
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  async def update_customer_user_mode(
61
  self,
62
  *,
@@ -269,7 +375,15 @@ class SupabaseRepository:
269
  if vehicle_type:
270
  query = query.ilike("driver_cars.car_type", f"%{vehicle_type}%")
271
  response = await query.limit(10).execute()
272
- return _response_data(response) or []
 
 
 
 
 
 
 
 
273
 
274
  async def search_info_chunks_by_vector(
275
  self,
@@ -600,3 +714,17 @@ class SupabaseRepository:
600
  .execute()
601
  )
602
  return response.count if hasattr(response, "count") and response.count is not None else 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  name: str | None = None,
40
  preferred_language: str | None = None,
41
  phone_number: str | None = None,
42
+ registered: bool = True,
43
  ) -> dict[str, Any]:
44
  if phone_number:
45
  phone_number = phone_number.split("@")[0]
 
48
  "name": name,
49
  "preferred_language": preferred_language,
50
  "phone_number": phone_number,
51
+ "registered": registered,
52
  }
53
  payload = {key: value for key, value in payload.items() if value is not None}
54
  response = await (
 
59
  data = _response_data(response)
60
  return data[0] if isinstance(data, list) else data
61
 
62
+ async def get_customer_by_phone_number(
63
+ self,
64
+ phone_number: str,
65
+ ) -> dict[str, Any] | None:
66
+ response = await (
67
+ self.client.table("customers")
68
+ .select("*")
69
+ .eq("phone_number", phone_number)
70
+ .maybe_single()
71
+ .execute()
72
+ )
73
+ return _response_data(response)
74
+
75
+ async def create_unregistered_driver_entities(
76
+ self,
77
+ *,
78
+ phone_number: str,
79
+ driver_name: str | None,
80
+ car_type: str | None,
81
+ departure: str,
82
+ destination: str,
83
+ departure_date: Any,
84
+ departure_time: str,
85
+ available_seats: int,
86
+ total_seats: int,
87
+ price: float,
88
+ ) -> dict[str, Any]:
89
+ customer = await self.upsert_customer(
90
+ remote_jid=phone_number,
91
+ name=driver_name,
92
+ phone_number=phone_number,
93
+ registered=False,
94
+ )
95
+ driver = await self.create_driver(customer_id=str(customer["id"]))
96
+ car = await self.create_driver_car(
97
+ driver_id=str(driver["id"]),
98
+ car_type=car_type or "غير معروف",
99
+ seat_count=total_seats,
100
+ )
101
+ trip = await self.create_driver_trip(
102
+ driver_id=str(driver["id"]),
103
+ car_id=str(car["id"]),
104
+ departure=departure,
105
+ destination=destination,
106
+ departure_date=departure_date,
107
+ departure_time=departure_time,
108
+ available_seats=available_seats,
109
+ total_seats=total_seats,
110
+ price=price,
111
+ )
112
+ return trip
113
+
114
+ async def create_unregistered_driver_trip(
115
+ self,
116
+ *,
117
+ driver_id: str | None,
118
+ phone_number: str,
119
+ driver_name: str | None,
120
+ car_type: str | None,
121
+ departure: str,
122
+ destination: str,
123
+ departure_date: Any,
124
+ departure_time: str,
125
+ available_seats: int,
126
+ total_seats: int,
127
+ price: float,
128
+ ) -> dict[str, Any]:
129
+ if not driver_id:
130
+ result = await self.create_unregistered_driver_entities(
131
+ phone_number=phone_number,
132
+ driver_name=driver_name,
133
+ car_type=car_type,
134
+ departure=departure,
135
+ destination=destination,
136
+ departure_date=departure_date,
137
+ departure_time=departure_time,
138
+ available_seats=available_seats,
139
+ total_seats=total_seats,
140
+ price=price,
141
+ )
142
+ return result
143
+
144
+ cars = await self.list_driver_cars(driver_id)
145
+ car = cars[0] if cars else None
146
+ if not car:
147
+ car = await self.create_driver_car(
148
+ driver_id=driver_id,
149
+ car_type=car_type or "غير معروف",
150
+ seat_count=total_seats,
151
+ )
152
+
153
+ trip = await self.create_driver_trip(
154
+ driver_id=driver_id,
155
+ car_id=str(car["id"]),
156
+ departure=departure,
157
+ destination=destination,
158
+ departure_date=departure_date,
159
+ departure_time=departure_time,
160
+ available_seats=available_seats,
161
+ total_seats=total_seats,
162
+ price=price,
163
+ )
164
+ return trip
165
+
166
  async def update_customer_user_mode(
167
  self,
168
  *,
 
375
  if vehicle_type:
376
  query = query.ilike("driver_cars.car_type", f"%{vehicle_type}%")
377
  response = await query.limit(10).execute()
378
+ data = _response_data(response) or []
379
+ data.sort(
380
+ key=lambda t: (
381
+ not _driver_is_registered(t),
382
+ str(t.get("departure_date") or ""),
383
+ str(t.get("departure_time") or ""),
384
+ )
385
+ )
386
+ return data
387
 
388
  async def search_info_chunks_by_vector(
389
  self,
 
714
  .execute()
715
  )
716
  return response.count if hasattr(response, "count") and response.count is not None else 0
717
+
718
+
719
+ def _driver_is_registered(trip: dict[str, Any]) -> bool:
720
+ drivers = trip.get("drivers")
721
+ if isinstance(drivers, list):
722
+ drivers = drivers[0] if drivers else {}
723
+ if not isinstance(drivers, dict):
724
+ return False
725
+ customer = drivers.get("customers")
726
+ if isinstance(customer, list):
727
+ customer = customer[0] if customer else {}
728
+ if not isinstance(customer, dict):
729
+ return False
730
+ return bool(customer.get("registered", False))
app/models/domain.py CHANGED
@@ -17,9 +17,25 @@ class WhatsAppInboundMessage:
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  @dataclass(slots=True)
24
  class ToolCall:
25
  id: str
 
17
  interactive_reply_id: str | None = None
18
  context_message_id: str | None = None
19
  phone_number: str | None = None
20
+ is_group: bool = False
21
  raw: dict[str, Any] = field(default_factory=dict)
22
 
23
 
24
+ @dataclass(slots=True)
25
+ class ExtractedTrip:
26
+ is_trip_ad: bool
27
+ departure: str | None = None
28
+ destination: str | None = None
29
+ departure_date: str | None = None
30
+ departure_time: str | None = None
31
+ available_seats: int | None = None
32
+ total_seats: int | None = None
33
+ price: float | None = None
34
+ car_type: str | None = None
35
+ driver_name: str | None = None
36
+ driver_phone: str | None = None
37
+
38
+
39
  @dataclass(slots=True)
40
  class ToolCall:
41
  id: str
app/services/container.py CHANGED
@@ -7,6 +7,7 @@ from app.database.supabase import SupabaseRepository, create_supabase_client
7
  from app.services.admin_service import AdminService
8
  from app.services.conversation_service import ConversationService
9
  from app.services.embedding_service import JinaEmbeddingService
 
10
  from app.whatsapp.client import WhatsAppClient
11
 
12
 
@@ -18,6 +19,7 @@ class ServiceContainer:
18
  whatsapp: WhatsAppClient
19
  ai: AIOrchestrator
20
  conversation: ConversationService
 
21
  admin: AdminService
22
 
23
  @classmethod
@@ -39,6 +41,12 @@ class ServiceContainer:
39
  ai=ai,
40
  settings=settings,
41
  )
 
 
 
 
 
 
42
  admin = AdminService(repository=repository, embeddings=embeddings, settings=settings)
43
  return cls(
44
  settings=settings,
@@ -47,5 +55,6 @@ class ServiceContainer:
47
  whatsapp=whatsapp,
48
  ai=ai,
49
  conversation=conversation,
 
50
  admin=admin,
51
  )
 
7
  from app.services.admin_service import AdminService
8
  from app.services.conversation_service import ConversationService
9
  from app.services.embedding_service import JinaEmbeddingService
10
+ from app.services.group_message_service import GroupMessageService
11
  from app.whatsapp.client import WhatsAppClient
12
 
13
 
 
19
  whatsapp: WhatsAppClient
20
  ai: AIOrchestrator
21
  conversation: ConversationService
22
+ group_message: GroupMessageService
23
  admin: AdminService
24
 
25
  @classmethod
 
41
  ai=ai,
42
  settings=settings,
43
  )
44
+ group_message = GroupMessageService(
45
+ repository=repository,
46
+ embeddings=embeddings,
47
+ provider=ai.primary,
48
+ settings=settings,
49
+ )
50
  admin = AdminService(repository=repository, embeddings=embeddings, settings=settings)
51
  return cls(
52
  settings=settings,
 
55
  whatsapp=whatsapp,
56
  ai=ai,
57
  conversation=conversation,
58
+ group_message=group_message,
59
  admin=admin,
60
  )
app/services/conversation_service.py CHANGED
@@ -50,6 +50,7 @@ class ConversationService:
50
  remote_jid=inbound.remoteJid,
51
  name=inbound.profile_name,
52
  phone_number=inbound.phone_number,
 
53
  )
54
 
55
  metadata: dict[str, Any] = {
@@ -103,9 +104,9 @@ class ConversationService:
103
  if result.ok:
104
  driver_phone = result.data.get("driver_phone")
105
  reply = (
106
- f"تم إرسال معلوماتك إلى السائق. يمكنك التواصل معه على الرقم: {driver_phone}"
107
  if driver_phone
108
- else "تم إرسال معلوماتك إلى السائق. سيتم التواصل معك."
109
  )
110
  else:
111
  reply = f"عذراً، لم يتم إرسال الطلب: {result.error}"
 
50
  remote_jid=inbound.remoteJid,
51
  name=inbound.profile_name,
52
  phone_number=inbound.phone_number,
53
+ registered=True,
54
  )
55
 
56
  metadata: dict[str, Any] = {
 
104
  if result.ok:
105
  driver_phone = result.data.get("driver_phone")
106
  reply = (
107
+ f"يمكنك التواصل مع السائق على الرقم: {driver_phone}"
108
  if driver_phone
109
+ else "يمكنك التواصل مع السائق"
110
  )
111
  else:
112
  reply = f"عذراً، لم يتم إرسال الطلب: {result.error}"
app/services/group_message_service.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ from datetime import date
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from app.ai.providers import ChatProvider
8
+ from app.config import Settings
9
+ from app.database.supabase import SupabaseRepository
10
+ from app.models.domain import ExtractedTrip, WhatsAppInboundMessage
11
+ from app.services.embedding_service import JinaEmbeddingService
12
+ from app.services.trip_indexing import index_trip
13
+ from app.utils.departure import normalize_departure_bucket, _parse_date_value
14
+ from app.utils.time import now_in_timezone
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _EXTRACTION_PROMPT_PATH = Path("prompts/group_trip_extraction.md")
19
+
20
+
21
+ class GroupMessageService:
22
+ def __init__(
23
+ self,
24
+ *,
25
+ repository: SupabaseRepository,
26
+ embeddings: JinaEmbeddingService,
27
+ provider: ChatProvider,
28
+ settings: Settings,
29
+ ) -> None:
30
+ self.repository = repository
31
+ self.embeddings = embeddings
32
+ self.provider = provider
33
+ self.settings = settings
34
+
35
+ async def handle_group_message(
36
+ self,
37
+ inbound: WhatsAppInboundMessage,
38
+ ) -> None:
39
+ if await self.repository.message_exists(inbound.message_id):
40
+ logger.info("Skipping duplicate group message %s", inbound.message_id)
41
+ return
42
+
43
+ extracted = await self._extract_trip_from_text(inbound.text)
44
+ if not extracted or not extracted.is_trip_ad:
45
+ logger.info("Group message %s is not a trip advertisement", inbound.message_id)
46
+ return
47
+
48
+ if not self._validate_extracted_trip(extracted):
49
+ logger.warning(
50
+ "Group message %s has incomplete trip data: %s",
51
+ inbound.message_id,
52
+ extracted,
53
+ )
54
+ return
55
+
56
+ departure_time = normalize_departure_bucket(extracted.departure_time)
57
+ if not departure_time:
58
+ logger.warning(
59
+ "Group message %s: could not normalize departure_time '%s'",
60
+ inbound.message_id,
61
+ extracted.departure_time,
62
+ )
63
+ return
64
+
65
+ departure_date = _parse_date_value(extracted.departure_date)
66
+ if not departure_date:
67
+ logger.warning(
68
+ "Group message %s: invalid departure_date '%s'",
69
+ inbound.message_id,
70
+ extracted.departure_date,
71
+ )
72
+ return
73
+
74
+ phone = self._normalize_phone(extracted.driver_phone)
75
+ if not phone:
76
+ logger.warning(
77
+ "Group message %s: no valid phone number extracted",
78
+ inbound.message_id,
79
+ )
80
+ return
81
+
82
+ existing_customer = await self.repository.get_customer_by_phone_number(phone)
83
+ if existing_customer:
84
+ if existing_customer.get("registered"):
85
+ logger.info(
86
+ "Driver phone %s is registered (customer %s); discarding group message %s",
87
+ phone,
88
+ existing_customer["id"],
89
+ inbound.message_id,
90
+ )
91
+ return
92
+
93
+ driver = await self.repository.get_driver_by_remoteJid(phone)
94
+ if driver:
95
+ existing_trip = await self.repository.get_driver_trip_by_datetime(
96
+ driver_id=str(driver["id"]),
97
+ departure_date=departure_date,
98
+ departure_time=departure_time,
99
+ )
100
+ if existing_trip:
101
+ logger.info(
102
+ "Driver %s already has trip at %s %s; discarding group message %s",
103
+ phone,
104
+ departure_date,
105
+ departure_time,
106
+ inbound.message_id,
107
+ )
108
+ return
109
+
110
+ trip = await self.repository.create_unregistered_driver_trip(
111
+ driver_id=str(driver["id"]) if driver else None,
112
+ phone_number=phone,
113
+ driver_name=extracted.driver_name,
114
+ car_type=extracted.car_type,
115
+ departure=extracted.departure,
116
+ destination=extracted.destination,
117
+ departure_date=departure_date,
118
+ departure_time=departure_time,
119
+ available_seats=extracted.available_seats or 1,
120
+ total_seats=extracted.total_seats or extracted.available_seats or 1,
121
+ price=extracted.price or 0,
122
+ )
123
+ else:
124
+ trip = await self.repository.create_unregistered_driver_entities(
125
+ phone_number=phone,
126
+ driver_name=extracted.driver_name,
127
+ car_type=extracted.car_type,
128
+ departure=extracted.departure,
129
+ destination=extracted.destination,
130
+ departure_date=departure_date,
131
+ departure_time=departure_time,
132
+ available_seats=extracted.available_seats or 1,
133
+ total_seats=extracted.total_seats or extracted.available_seats or 1,
134
+ price=extracted.price or 0,
135
+ )
136
+
137
+ await index_trip(
138
+ repository=self.repository,
139
+ embeddings=self.embeddings,
140
+ embedding_model=self.settings.jina_embedding_model,
141
+ trip=trip,
142
+ )
143
+
144
+ logger.info(
145
+ "Created trip %s from group message (unregistered driver %s)",
146
+ trip.get("id"),
147
+ phone,
148
+ )
149
+
150
+ async def _extract_trip_from_text(self, text: str) -> ExtractedTrip | None:
151
+ prompt_template = _EXTRACTION_PROMPT_PATH.read_text(encoding="utf-8")
152
+ dt = now_in_timezone(self.settings.app_timezone)
153
+ prompt = prompt_template.format(current_datetime=dt.isoformat())
154
+
155
+ try:
156
+ response = await self.provider.chat(
157
+ messages=[
158
+ {"role": "system", "content": prompt},
159
+ {"role": "user", "content": text},
160
+ ],
161
+ tools=None,
162
+ tool_choice=None,
163
+ temperature=0.1,
164
+ )
165
+ except Exception as exc:
166
+ logger.warning("LLM extraction failed for group message: %s", exc)
167
+ return None
168
+
169
+ content = (response.content or "").strip()
170
+ if not content:
171
+ return None
172
+
173
+ return self._parse_extracted_json(content)
174
+
175
+ def _parse_extracted_json(self, content: str) -> ExtractedTrip | None:
176
+ if content.startswith("```"):
177
+ lines = content.split("\n")
178
+ content = "\n".join(lines[1:-1])
179
+ content = content.strip()
180
+
181
+ try:
182
+ data = json.loads(content)
183
+ except json.JSONDecodeError:
184
+ logger.warning("Failed to parse LLM extraction JSON: %s", content[:200])
185
+ return None
186
+
187
+ if not isinstance(data, dict):
188
+ return None
189
+
190
+ return ExtractedTrip(
191
+ is_trip_ad=bool(data.get("is_trip_ad", False)),
192
+ departure=data.get("departure"),
193
+ destination=data.get("destination"),
194
+ departure_date=data.get("departure_date"),
195
+ departure_time=data.get("departure_time"),
196
+ available_seats=_safe_int(data.get("available_seats")),
197
+ total_seats=_safe_int(data.get("total_seats")),
198
+ price=_safe_float(data.get("price")),
199
+ car_type=data.get("car_type"),
200
+ driver_name=data.get("driver_name"),
201
+ driver_phone=data.get("driver_phone"),
202
+ )
203
+
204
+ def _validate_extracted_trip(self, trip: ExtractedTrip) -> bool:
205
+ return all([
206
+ trip.departure,
207
+ trip.destination,
208
+ trip.departure_date,
209
+ trip.departure_time,
210
+ trip.driver_phone,
211
+ ])
212
+
213
+ @staticmethod
214
+ def _normalize_phone(phone: str | None) -> str | None:
215
+ if not phone:
216
+ return None
217
+ digits = "".join(c for c in phone if c.isdigit())
218
+ if len(digits) < 7:
219
+ return None
220
+ digits = digits.lstrip("0")
221
+ return digits if digits else None
222
+
223
+
224
+ def _safe_int(value: Any) -> int | None:
225
+ if value is None:
226
+ return None
227
+ try:
228
+ return int(value)
229
+ except (TypeError, ValueError):
230
+ return None
231
+
232
+
233
+ def _safe_float(value: Any) -> float | None:
234
+ if value is None:
235
+ return None
236
+ try:
237
+ return float(value)
238
+ except (TypeError, ValueError):
239
+ return None
app/tools/handlers.py CHANGED
@@ -253,28 +253,31 @@ class FalsaToolHandlers:
253
  notes=notes,
254
  )
255
 
256
- driver_phone: str | None = None
257
- notification_status = "sent"
 
 
 
 
258
  notification_error = None
259
- try:
260
- driver_record = _first_or_dict(trip.get("drivers")) or {}
261
- driver_customer = driver_record.get("customers") or {}
262
- driver_recipient = driver_customer.get("phone_number") or driver_customer.get("remoteJid") or driver_record.get("remoteJid")
263
- if not driver_recipient:
264
- raise WhatsAppClientError("Driver recipient is missing")
265
- driver_phone = driver_recipient.split("@")[0]
266
- await self.whatsapp.send_text(
267
- driver_recipient,
268
- _driver_notification_text(
269
- customer=self.customer,
270
- trip=trip,
271
- requested_seats=requested_seats,
272
- notes=notes,
273
- ),
274
- )
275
- except Exception as exc: # noqa: BLE001
276
- notification_status = "failed"
277
- notification_error = str(exc)
278
 
279
  await self.repository.update_selection_notification(
280
  selection_id=str(selection["id"]),
@@ -290,7 +293,7 @@ class FalsaToolHandlers:
290
  "driver_notification_status": notification_status,
291
  "driver_notification_error": notification_error,
292
  "driver_phone": driver_phone,
293
- "message": "Customer interest recorded. Driver has been notified — share their number so the two parties can coordinate directly.",
294
  },
295
  )
296
 
@@ -979,6 +982,7 @@ def _trip_summary(trip: dict[str, Any], *, trip_number: int | None = None) -> di
979
  "similarity": trip.get("similarity"),
980
  "time_difference_minutes": trip.get("time_difference_minutes"),
981
  "selection_count": trip.get("selection_count", 0),
 
982
  }
983
  if trip_number is not None:
984
  summary["trip_number"] = trip_number
@@ -990,6 +994,7 @@ def _sort_trip_summaries(trips: list[dict[str, Any]]) -> list[dict[str, Any]]:
990
  return sorted(
991
  trips,
992
  key=lambda trip: (
 
993
  str(trip.get("departure_date") or ""),
994
  bucket_order.get(str(trip.get("departure_time_type") or ""), 99),
995
  ),
@@ -1004,6 +1009,20 @@ def _first_or_dict(value: Any) -> dict[str, Any] | None:
1004
  return None
1005
 
1006
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1007
  def _alternate_time_alert(trips: list[dict[str, Any]]) -> str | None:
1008
  if not trips:
1009
  return None
 
253
  notes=notes,
254
  )
255
 
256
+ driver_record = _first_or_dict(trip.get("drivers")) or {}
257
+ driver_customer = driver_record.get("customers") or {}
258
+ driver_recipient = driver_customer.get("phone_number") or driver_customer.get("remoteJid") or driver_record.get("remoteJid")
259
+ driver_phone = driver_recipient.split("@")[0] if driver_recipient else None
260
+
261
+ notification_status = "not_sent"
262
  notification_error = None
263
+
264
+ is_registered = _driver_is_registered(trip)
265
+
266
+ if is_registered and driver_recipient:
267
+ try:
268
+ await self.whatsapp.send_text(
269
+ driver_recipient,
270
+ _driver_notification_text(
271
+ customer=self.customer,
272
+ trip=trip,
273
+ requested_seats=requested_seats,
274
+ notes=notes,
275
+ ),
276
+ )
277
+ notification_status = "sent"
278
+ except Exception as exc: # noqa: BLE001
279
+ notification_status = "failed"
280
+ notification_error = str(exc)
 
281
 
282
  await self.repository.update_selection_notification(
283
  selection_id=str(selection["id"]),
 
293
  "driver_notification_status": notification_status,
294
  "driver_notification_error": notification_error,
295
  "driver_phone": driver_phone,
296
+ "message": "Customer interest recorded. Share driver number so the two parties can coordinate directly.",
297
  },
298
  )
299
 
 
982
  "similarity": trip.get("similarity"),
983
  "time_difference_minutes": trip.get("time_difference_minutes"),
984
  "selection_count": trip.get("selection_count", 0),
985
+ "registered": _driver_is_registered(trip),
986
  }
987
  if trip_number is not None:
988
  summary["trip_number"] = trip_number
 
994
  return sorted(
995
  trips,
996
  key=lambda trip: (
997
+ not trip.get("registered", False),
998
  str(trip.get("departure_date") or ""),
999
  bucket_order.get(str(trip.get("departure_time_type") or ""), 99),
1000
  ),
 
1009
  return None
1010
 
1011
 
1012
+ def _driver_is_registered(trip: dict[str, Any]) -> bool:
1013
+ drivers = trip.get("drivers")
1014
+ if isinstance(drivers, list):
1015
+ drivers = drivers[0] if drivers else {}
1016
+ if not isinstance(drivers, dict):
1017
+ return False
1018
+ customer = drivers.get("customers")
1019
+ if isinstance(customer, list):
1020
+ customer = customer[0] if customer else {}
1021
+ if not isinstance(customer, dict):
1022
+ return False
1023
+ return bool(customer.get("registered", False))
1024
+
1025
+
1026
  def _alternate_time_alert(trips: list[dict[str, Any]]) -> str | None:
1027
  if not trips:
1028
  return None
app/whatsapp/parser.py CHANGED
@@ -23,6 +23,7 @@ def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessa
23
  continue
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")
@@ -46,6 +47,7 @@ def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessa
46
  message_type="text",
47
  context_message_id=context_message_id,
48
  phone_number=phone_number,
 
49
  raw=message,
50
  )
51
  )
@@ -73,6 +75,7 @@ def parse_inbound_messages(payload: dict[str, Any]) -> list[WhatsAppInboundMessa
73
  interactive_reply_id=reply_id,
74
  context_message_id=context_message_id,
75
  phone_number=phone_number,
 
76
  raw=message,
77
  )
78
  )
 
23
  continue
24
 
25
  remoteJid = message.get("from")
26
+ is_group = isinstance(remoteJid, str) and remoteJid.endswith("@g.us")
27
  message_id = message.get("id")
28
  phone_number = message.get("remote_jid_alt")
29
  context_message_id = message.get("context", {}).get("id")
 
47
  message_type="text",
48
  context_message_id=context_message_id,
49
  phone_number=phone_number,
50
+ is_group=is_group,
51
  raw=message,
52
  )
53
  )
 
75
  interactive_reply_id=reply_id,
76
  context_message_id=context_message_id,
77
  phone_number=phone_number,
78
+ is_group=is_group,
79
  raw=message,
80
  )
81
  )
prompts/group_trip_extraction.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a trip advertisement extractor. Analyze the following WhatsApp group message.
2
+
3
+ Your task:
4
+ 1. Determine if this message is a trip/travel advertisement posted by a driver.
5
+ 2. If it IS a trip ad, extract ALL available fields.
6
+
7
+ A trip advertisement typically mentions:
8
+ - A departure city/area and destination city/area
9
+ - A date or time of departure
10
+ - A price per seat
11
+ - A phone number (could be labeled like "رقم:" or "هاتف:" or just a phone pattern)
12
+ - Optional: number of seats, car type, driver name
13
+
14
+ Response format: Return ONLY a JSON object, no other text.
15
+
16
+ If this IS a trip advertisement:
17
+ ```json
18
+ {{
19
+ "is_trip_ad": true,
20
+ "departure": "string - departure city/area in Arabic as written in message",
21
+ "destination": "string - destination city/area in Arabic as written in message",
22
+ "departure_date": "string - YYYY-MM-DD format, derive from message context",
23
+ "departure_time": "string - morning, noon, or night (based on time in message)",
24
+ "available_seats": "integer or null",
25
+ "total_seats": "integer or null",
26
+ "price": "number - price per seat",
27
+ "car_type": "string or null - vehicle type if mentioned",
28
+ "driver_name": "string or null - driver name if mentioned",
29
+ "driver_phone": "string - phone number extracted from message text"
30
+ }}
31
+ ```
32
+
33
+ If this is NOT a trip advertisement:
34
+ ```json
35
+ {{
36
+ "is_trip_ad": false
37
+ }}
38
+ ```
39
+
40
+ Rules:
41
+ - The phone number must come from the MESSAGE TEXT, not from any metadata.
42
+ - For departure_date: if the message says "بكرة" (tomorrow) or "اليوم" (today), convert to actual YYYY-MM-DD using the current date {current_datetime}.
43
+ - For departure_time: map to morning (before 12:00), noon (12:00-17:59), night (18:00+). If the message says "صباح" use morning, "ظهر" use noon, "ليل" or "مساء" use night.
44
+ - If the price is mentioned as a number without currency, use it as-is.
45
+ - Return ONLY the JSON object. No explanation, no markdown fences.
prompts/system.md CHANGED
@@ -2,6 +2,7 @@ You are FALSA, a WhatsApp travel booking assistant.
2
 
3
  Rules:
4
  - Reply in sender's language (Arabic/English). Be concise, warm, practical.
 
5
  - Use `about_falsa` for company/FAQ/policy/pricing/support.
6
  - Tool args: Arabic for departure/destination/vehicle_type/time labels. English for digits/times.
7
  - Never invent trips, prices, drivers, or bookings.
 
2
 
3
  Rules:
4
  - Reply in sender's language (Arabic/English). Be concise, warm, practical.
5
+ - For new users: be extra warm and welcoming. Greet them, introduce FALSA briefly, and guide them to their first action.
6
  - Use `about_falsa` for company/FAQ/policy/pricing/support.
7
  - Tool args: Arabic for departure/destination/vehicle_type/time labels. English for digits/times.
8
  - Never invent trips, prices, drivers, or bookings.
prompts/system_new_user.md CHANGED
@@ -1,3 +1,10 @@
1
- Welcome new sender. Ask: travel as passenger (search/book) or work as driver (publish trips)?
2
- - To travel: switch_to_passenger (name optional, ask only if offered). Don't ask name before switching.
3
- - To drive: try switch_to_driver. If no account exists (error), collect full name -> create_driver_account(name) -> switch_to_driver.
 
 
 
 
 
 
 
 
1
+ It is the sender's first message. Welcome them warmly and introduce FALSA with a short usage guide.
2
+
3
+ 1. Greet warmly (e.g. "مرحباً بك في فلزا").
4
+ 2. Explain what FALSA is in 1 sentence: a WhatsApp travel service powered by AI — no app to install.
5
+ 3. Give a quick how-to guide showing both paths clearly:
6
+ - المسافر: اكتب وجهتك (مثلاً "من صنعاء إلى عدن بكرة الصباح") ← فلزا تبحث ← رد على البطاقة ← تتواصل مع السائق. Do NOT ask for name.
7
+ - السائق: سجل كسائق ← أضف مركبتك ← انشر رحلاتك. Collect their full name, then create_driver_account(name) -> switch_to_driver.
8
+ 4. Use emojis (🚗, 🚚, etc.) and friendly tone. Be warm, not robotic.
9
+ 5. Keep it short — 4-5 lines max. Do not over-explain.
10
+ 6. Never ask for name of a passenger. Only ask for name if they want to become a driver.
supabase/migrations/202607120001_group_trip_support.sql ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- 1. Add `registered` boolean to customers
2
+ -- All existing customers are private-chat users, so backfill to true
3
+ ALTER TABLE public.customers
4
+ ADD COLUMN IF NOT EXISTS registered boolean NOT NULL DEFAULT false;
5
+
6
+ UPDATE public.customers SET registered = true;
7
+
8
+ -- 2. Modify match_active_trips RPC to include registered status
9
+ -- and order by registered DESC (registered drivers first)
10
+ DROP FUNCTION IF EXISTS public.match_active_trips(
11
+ extensions.vector(1024), float, int, text, text, date, text, time, int, text
12
+ );
13
+
14
+ CREATE OR REPLACE FUNCTION public.match_active_trips(
15
+ query_embedding extensions.vector(1024),
16
+ match_threshold float DEFAULT 0.0,
17
+ match_count int DEFAULT 10,
18
+ filter_departure text DEFAULT NULL,
19
+ filter_destination text DEFAULT NULL,
20
+ filter_departure_date date DEFAULT NULL,
21
+ filter_departure_time text DEFAULT NULL,
22
+ filter_requested_time time DEFAULT NULL,
23
+ filter_seats int DEFAULT 1,
24
+ filter_vehicle_type text DEFAULT NULL
25
+ )
26
+ RETURNS TABLE (
27
+ trip_id uuid,
28
+ departure text,
29
+ destination text,
30
+ departure_date date,
31
+ departure_time text,
32
+ available_seats integer,
33
+ total_seats integer,
34
+ price numeric,
35
+ status text,
36
+ driver_name text,
37
+ driver_phone_number text,
38
+ car_type text,
39
+ chunk_text text,
40
+ similarity float,
41
+ time_difference_minutes integer,
42
+ registered boolean
43
+ )
44
+ LANGUAGE sql STABLE
45
+ AS $$
46
+ WITH ranked AS (
47
+ SELECT
48
+ driver_trips.id AS trip_id,
49
+ driver_trips.departure,
50
+ driver_trips.destination,
51
+ driver_trips.departure_date,
52
+ driver_trips.departure_time,
53
+ driver_trips.available_seats,
54
+ driver_trips.total_seats,
55
+ driver_trips.price,
56
+ driver_trips.status,
57
+ customers.name AS driver_name,
58
+ customers."remoteJid" AS driver_phone_number,
59
+ driver_cars.car_type,
60
+ driver_trip_embeddings.chunk_text,
61
+ COALESCE(customers.registered, false) AS registered,
62
+ 1 - (driver_trip_embeddings.embedding <=> query_embedding) AS similarity,
63
+ driver_trip_embeddings.embedding <=> query_embedding AS vector_distance,
64
+ CASE
65
+ WHEN filter_requested_time IS NULL THEN NULL
66
+ ELSE abs(
67
+ extract(epoch FROM (
68
+ public.departure_bucket_clock_time(driver_trips.departure_time)
69
+ - filter_requested_time
70
+ )) / 60
71
+ )::integer
72
+ END AS time_difference_minutes
73
+ FROM public.driver_trip_embeddings
74
+ JOIN public.driver_trips ON driver_trips.id = driver_trip_embeddings.trip_id
75
+ LEFT JOIN public.drivers ON drivers.id = driver_trips.driver_id
76
+ LEFT JOIN public.customers ON customers.id = drivers.customer_id
77
+ LEFT JOIN public.driver_cars ON driver_cars.id = driver_trips.car_id
78
+ WHERE driver_trips.status = 'active'
79
+ AND driver_trips.available_seats >= COALESCE(filter_seats, 1)
80
+ AND (filter_departure IS NULL OR driver_trips.departure ILIKE '%' || filter_departure || '%')
81
+ AND (filter_destination IS NULL OR driver_trips.destination ILIKE '%' || filter_destination || '%')
82
+ AND (filter_departure_date IS NULL OR driver_trips.departure_date = filter_departure_date)
83
+ AND (filter_departure_time IS NULL OR driver_trips.departure_time = filter_departure_time)
84
+ AND (filter_vehicle_type IS NULL OR driver_cars.car_type ILIKE '%' || filter_vehicle_type || '%')
85
+ AND (
86
+ driver_trips.departure_date > (NOW() AT TIME ZONE 'Asia/Aden')::date
87
+ OR (
88
+ driver_trips.departure_date = (NOW() AT TIME ZONE 'Asia/Aden')::date
89
+ AND (
90
+ (NOW() AT TIME ZONE 'Asia/Aden')::time < TIME '12:00'
91
+ OR (
92
+ (NOW() AT TIME ZONE 'Asia/Aden')::time < TIME '18:00'
93
+ AND driver_trips.departure_time IN ('noon', 'night')
94
+ )
95
+ OR (
96
+ (NOW() AT TIME ZONE 'Asia/Aden')::time >= TIME '18:00'
97
+ AND driver_trips.departure_time = 'night'
98
+ )
99
+ )
100
+ )
101
+ )
102
+ )
103
+ SELECT
104
+ ranked.trip_id,
105
+ ranked.departure,
106
+ ranked.destination,
107
+ ranked.departure_date,
108
+ ranked.departure_time,
109
+ ranked.available_seats,
110
+ ranked.total_seats,
111
+ ranked.price,
112
+ ranked.status,
113
+ ranked.driver_name,
114
+ ranked.driver_phone_number,
115
+ ranked.car_type,
116
+ ranked.chunk_text,
117
+ ranked.similarity,
118
+ ranked.time_difference_minutes,
119
+ ranked.registered
120
+ FROM ranked
121
+ WHERE ranked.similarity >= match_threshold
122
+ ORDER BY
123
+ ranked.registered DESC,
124
+ ranked.time_difference_minutes NULLS LAST,
125
+ ranked.departure_date,
126
+ ranked.vector_distance
127
+ LIMIT match_count;
128
+ $$;
tests/conftest.py CHANGED
@@ -99,6 +99,7 @@ def test_app(settings: Settings) -> Any:
99
  class FakeRepository:
100
  def __init__(self) -> None:
101
  self.customers_by_remote_jid: dict[str, dict[str, Any]] = {}
 
102
  self.drivers_by_remote_jid: dict[str, dict[str, Any]] = {}
103
  self.driver_cars_by_driver: dict[str, list[dict[str, Any]]] = {}
104
  self.latest_trips_by_driver: dict[str, dict[str, Any]] = {}
@@ -121,6 +122,7 @@ class FakeRepository:
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:
@@ -135,12 +137,17 @@ class FakeRepository:
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(
@@ -167,6 +174,102 @@ class FakeRepository:
167
  return customer
168
  raise KeyError(customer_id)
169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  def _find_customer_by_id(self, customer_id: str) -> dict[str, Any] | None:
171
  for customer in self.customers_by_remote_jid.values():
172
  if customer["id"] == customer_id:
 
99
  class FakeRepository:
100
  def __init__(self) -> None:
101
  self.customers_by_remote_jid: dict[str, dict[str, Any]] = {}
102
+ self.customers_by_phone: dict[str, dict[str, Any]] = {}
103
  self.drivers_by_remote_jid: dict[str, dict[str, Any]] = {}
104
  self.driver_cars_by_driver: dict[str, list[dict[str, Any]]] = {}
105
  self.latest_trips_by_driver: dict[str, dict[str, Any]] = {}
 
122
  name: str | None = None,
123
  preferred_language: str | None = None,
124
  phone_number: str | None = None,
125
+ registered: bool = True,
126
  ) -> dict[str, Any]:
127
  jid = remote_jid or phone_number
128
  if not jid:
 
137
  "phone_number": phone_number,
138
  "user_mode": None,
139
  "session_data": {},
140
+ "registered": registered,
141
  }
142
  self.customers_by_remote_jid[jid] = customer
143
+ if phone_number:
144
+ self.customers_by_phone[phone_number] = customer
145
  elif name:
146
  customer["name"] = name
147
  if phone_number:
148
  customer["phone_number"] = phone_number
149
+ self.customers_by_phone[phone_number] = customer
150
+ customer["registered"] = registered
151
  return customer
152
 
153
  async def update_customer_user_mode(
 
174
  return customer
175
  raise KeyError(customer_id)
176
 
177
+ async def get_customer_by_phone_number(
178
+ self,
179
+ phone_number: str,
180
+ ) -> dict[str, Any] | None:
181
+ return self.customers_by_phone.get(phone_number)
182
+
183
+ async def create_unregistered_driver_entities(
184
+ self,
185
+ *,
186
+ phone_number: str,
187
+ driver_name: str | None,
188
+ car_type: str | None,
189
+ departure: str,
190
+ destination: str,
191
+ departure_date: Any,
192
+ departure_time: str,
193
+ available_seats: int,
194
+ total_seats: int,
195
+ price: float,
196
+ ) -> dict[str, Any]:
197
+ customer = await self.upsert_customer(
198
+ remote_jid=phone_number,
199
+ name=driver_name,
200
+ phone_number=phone_number,
201
+ registered=False,
202
+ )
203
+ driver = await self.create_driver(customer_id=str(customer["id"]))
204
+ car = await self.create_driver_car(
205
+ driver_id=str(driver["id"]),
206
+ car_type=car_type or "غير معروف",
207
+ seat_count=total_seats,
208
+ )
209
+ trip = await self.create_driver_trip(
210
+ driver_id=str(driver["id"]),
211
+ car_id=str(car["id"]),
212
+ departure=departure,
213
+ destination=destination,
214
+ departure_date=departure_date,
215
+ departure_time=departure_time,
216
+ available_seats=available_seats,
217
+ total_seats=total_seats,
218
+ price=price,
219
+ )
220
+ return trip
221
+
222
+ async def create_unregistered_driver_trip(
223
+ self,
224
+ *,
225
+ driver_id: str | None,
226
+ phone_number: str,
227
+ driver_name: str | None,
228
+ car_type: str | None,
229
+ departure: str,
230
+ destination: str,
231
+ departure_date: Any,
232
+ departure_time: str,
233
+ available_seats: int,
234
+ total_seats: int,
235
+ price: float,
236
+ ) -> dict[str, Any]:
237
+ if not driver_id:
238
+ return await self.create_unregistered_driver_entities(
239
+ phone_number=phone_number,
240
+ driver_name=driver_name,
241
+ car_type=car_type,
242
+ departure=departure,
243
+ destination=destination,
244
+ departure_date=departure_date,
245
+ departure_time=departure_time,
246
+ available_seats=available_seats,
247
+ total_seats=total_seats,
248
+ price=price,
249
+ )
250
+
251
+ cars = self.driver_cars_by_driver.get(driver_id, [])
252
+ car = cars[0] if cars else None
253
+ if not car:
254
+ car = await self.create_driver_car(
255
+ driver_id=driver_id,
256
+ car_type=car_type or "غير معروف",
257
+ seat_count=total_seats,
258
+ )
259
+
260
+ trip = await self.create_driver_trip(
261
+ driver_id=driver_id,
262
+ car_id=str(car["id"]),
263
+ departure=departure,
264
+ destination=destination,
265
+ departure_date=departure_date,
266
+ departure_time=departure_time,
267
+ available_seats=available_seats,
268
+ total_seats=total_seats,
269
+ price=price,
270
+ )
271
+ return trip
272
+
273
  def _find_customer_by_id(self, customer_id: str) -> dict[str, Any] | None:
274
  for customer in self.customers_by_remote_jid.values():
275
  if customer["id"] == customer_id:
tests/test_conversation_service.py CHANGED
@@ -40,7 +40,7 @@ async def test_conversation_stores_messages_uses_last_four_context_and_sends_rep
40
 
41
  ai_messages = ai.calls[0]["messages"]
42
  assert ai_messages[0]["role"] == "system"
43
- assert "travel as passenger" in ai_messages[0]["content"]
44
  context_contents = [message["content"] for message in ai_messages[1:]]
45
  assert context_contents[-1] == "Aden to Mukalla tomorrow"
46
  assert len(context_contents) <= 9
 
40
 
41
  ai_messages = ai.calls[0]["messages"]
42
  assert ai_messages[0]["role"] == "system"
43
+ assert "FALSA" in ai_messages[0]["content"]
44
  context_contents = [message["content"] for message in ai_messages[1:]]
45
  assert context_contents[-1] == "Aden to Mukalla tomorrow"
46
  assert len(context_contents) <= 9
tests/test_group_message_service.py ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from datetime import UTC, date, datetime
3
+ from typing import Any
4
+ from unittest.mock import AsyncMock
5
+
6
+ import pytest
7
+
8
+ from app.config import Settings
9
+ from app.models.domain import AIProviderResponse, ExtractedTrip, WhatsAppInboundMessage
10
+ from app.services.group_message_service import GroupMessageService
11
+ from tests.conftest import FakeRepository, FakeEmbeddings
12
+
13
+
14
+ class FakeProvider:
15
+ def __init__(self, response_content: str | None = None) -> None:
16
+ self.response_content = response_content
17
+ self.calls: list[dict[str, Any]] = []
18
+
19
+ async def chat(
20
+ self,
21
+ messages: list[dict[str, Any]],
22
+ *,
23
+ tools: list[dict[str, Any]] | None = None,
24
+ tool_choice: str | None = None,
25
+ temperature: float = 0.2,
26
+ ) -> AIProviderResponse:
27
+ self.calls.append({"messages": messages, "tools": tools, "temperature": temperature})
28
+ return AIProviderResponse(content=self.response_content)
29
+
30
+
31
+ @pytest.fixture
32
+ def settings() -> Settings:
33
+ return Settings(
34
+ supabase_url="https://example.supabase.co",
35
+ supabase_service_role_key="supabase-secret",
36
+ jina_api_key="jina-secret",
37
+ groq_api_key="groq-secret",
38
+ groq_model="groq-tool-model",
39
+ hf_token="hf-secret",
40
+ hf_model="hf-tool-model",
41
+ whatsapp_verify_token="verify-token",
42
+ whatsapp_app_secret="app-secret",
43
+ whatsapp_access_token="wa-token",
44
+ whatsapp_phone_number_id="123",
45
+ admin_api_key="admin-secret",
46
+ )
47
+
48
+
49
+ def _make_inbound(
50
+ text: str,
51
+ message_id: str = "wamid.group.1",
52
+ ) -> WhatsAppInboundMessage:
53
+ return WhatsAppInboundMessage(
54
+ message_id=message_id,
55
+ remoteJid="12345@g.us",
56
+ text=text,
57
+ timestamp="1790000000",
58
+ is_group=True,
59
+ )
60
+
61
+
62
+ def _trip_ad_response(
63
+ *,
64
+ departure: str = "صنعاء",
65
+ destination: str = "عدن",
66
+ departure_date: str = "2026-07-15",
67
+ departure_time: str = "morning",
68
+ price: float = 5000,
69
+ driver_phone: str = "967712345678",
70
+ driver_name: str | None = "أحمد",
71
+ car_type: str | None = "سيارة",
72
+ available_seats: int | None = 4,
73
+ total_seats: int | None = 4,
74
+ ) -> str:
75
+ return json.dumps({
76
+ "is_trip_ad": True,
77
+ "departure": departure,
78
+ "destination": destination,
79
+ "departure_date": departure_date,
80
+ "departure_time": departure_time,
81
+ "available_seats": available_seats,
82
+ "total_seats": total_seats,
83
+ "price": price,
84
+ "car_type": car_type,
85
+ "driver_name": driver_name,
86
+ "driver_phone": driver_phone,
87
+ })
88
+
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_non_trip_message_is_ignored(settings: Settings) -> None:
92
+ repo = FakeRepository()
93
+ embeddings = FakeEmbeddings()
94
+ provider = FakeProvider(response_content=json.dumps({"is_trip_ad": False}))
95
+
96
+ service = GroupMessageService(
97
+ repository=repo,
98
+ embeddings=embeddings,
99
+ provider=provider,
100
+ settings=settings,
101
+ )
102
+
103
+ inbound = _make_inbound(text="مرحبا بالجميع")
104
+ await service.handle_group_message(inbound)
105
+
106
+ assert len(repo.customers_by_remote_jid) == 0
107
+ assert len(repo.created_drivers) == 0
108
+ assert len(repo.created_trips) == 0
109
+
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_trip_ad_from_new_driver_creates_all_entities(settings: Settings) -> None:
113
+ repo = FakeRepository()
114
+ embeddings = FakeEmbeddings()
115
+ provider = FakeProvider(response_content=_trip_ad_response())
116
+
117
+ service = GroupMessageService(
118
+ repository=repo,
119
+ embeddings=embeddings,
120
+ provider=provider,
121
+ settings=settings,
122
+ )
123
+
124
+ inbound = _make_inbound(text="رحلة من صنعاء إلى عدن بكرة الصباح 5000 ريال 967712345678")
125
+ await service.handle_group_message(inbound)
126
+
127
+ assert len(repo.customers_by_remote_jid) == 1
128
+ customer = list(repo.customers_by_remote_jid.values())[0]
129
+ assert customer["registered"] is False
130
+ assert customer["phone_number"] == "967712345678"
131
+
132
+ assert len(repo.created_drivers) == 1
133
+ assert len(repo.created_trips) == 1
134
+
135
+ trip = repo.created_trips[0]
136
+ assert trip["departure"] == "صنعاء"
137
+ assert trip["destination"] == "عدن"
138
+
139
+
140
+ @pytest.mark.asyncio
141
+ async def test_trip_ad_from_existing_customer_is_discarded(settings: Settings) -> None:
142
+ repo = FakeRepository()
143
+ embeddings = FakeEmbeddings()
144
+ provider = FakeProvider(response_content=_trip_ad_response())
145
+
146
+ existing_customer = await repo.upsert_customer(
147
+ remote_jid="967712345678",
148
+ name="أحمد",
149
+ phone_number="967712345678",
150
+ registered=True,
151
+ )
152
+
153
+ service = GroupMessageService(
154
+ repository=repo,
155
+ embeddings=embeddings,
156
+ provider=provider,
157
+ settings=settings,
158
+ )
159
+
160
+ inbound = _make_inbound(text="رحلة من صنعاء إلى عدن 967712345678")
161
+ await service.handle_group_message(inbound)
162
+
163
+ assert len(repo.created_drivers) == 0
164
+ assert len(repo.created_trips) == 0
165
+
166
+
167
+ @pytest.mark.asyncio
168
+ async def test_existing_unregistered_driver_adds_new_trip(settings: Settings) -> None:
169
+ repo = FakeRepository()
170
+ embeddings = FakeEmbeddings()
171
+ provider = FakeProvider(response_content=_trip_ad_response())
172
+
173
+ await repo.upsert_customer(
174
+ remote_jid="967712345678",
175
+ name="أحمد",
176
+ phone_number="967712345678",
177
+ registered=False,
178
+ )
179
+
180
+ service = GroupMessageService(
181
+ repository=repo,
182
+ embeddings=embeddings,
183
+ provider=provider,
184
+ settings=settings,
185
+ )
186
+
187
+ inbound = _make_inbound(text="رحلة من صنعاء إلى عدن")
188
+ await service.handle_group_message(inbound)
189
+
190
+ assert len(repo.created_trips) == 1
191
+ assert repo.created_trips[0]["departure"] == "صنعاء"
192
+
193
+
194
+ @pytest.mark.asyncio
195
+ async def test_existing_unregistered_driver_duplicate_trip_is_skipped(settings: Settings) -> None:
196
+ repo = FakeRepository()
197
+ embeddings = FakeEmbeddings()
198
+ provider = FakeProvider(response_content=_trip_ad_response())
199
+
200
+ await repo.upsert_customer(
201
+ remote_jid="967712345678",
202
+ name="أحمد",
203
+ phone_number="967712345678",
204
+ registered=False,
205
+ )
206
+ driver = await repo.create_driver(
207
+ customer_id=list(repo.customers_by_remote_jid.values())[0]["id"]
208
+ )
209
+ await repo.create_driver_trip(
210
+ driver_id=str(driver["id"]),
211
+ car_id=None,
212
+ departure="صنعاء",
213
+ destination="عدن",
214
+ departure_date=date(2026, 7, 15),
215
+ departure_time="morning",
216
+ available_seats=4,
217
+ total_seats=4,
218
+ price=5000,
219
+ )
220
+
221
+ service = GroupMessageService(
222
+ repository=repo,
223
+ embeddings=embeddings,
224
+ provider=provider,
225
+ settings=settings,
226
+ )
227
+
228
+ initial_count = len(repo.created_trips)
229
+ inbound = _make_inbound(text="رحلة من صنعاء إلى عدن")
230
+ await service.handle_group_message(inbound)
231
+
232
+ assert len(repo.created_trips) == initial_count
233
+
234
+
235
+ @pytest.mark.asyncio
236
+ async def test_incomplete_fields_cause_skip(settings: Settings) -> None:
237
+ repo = FakeRepository()
238
+ embeddings = FakeEmbeddings()
239
+ response = json.dumps({
240
+ "is_trip_ad": True,
241
+ "departure": "صنعاء",
242
+ "destination": None,
243
+ "departure_date": "2026-07-15",
244
+ "departure_time": "morning",
245
+ "price": 5000,
246
+ "driver_phone": "967712345678",
247
+ })
248
+ provider = FakeProvider(response_content=response)
249
+
250
+ service = GroupMessageService(
251
+ repository=repo,
252
+ embeddings=embeddings,
253
+ provider=provider,
254
+ settings=settings,
255
+ )
256
+
257
+ inbound = _make_inbound(text="رحلة من صنعاء")
258
+ await service.handle_group_message(inbound)
259
+
260
+ assert len(repo.created_trips) == 0
261
+
262
+
263
+ @pytest.mark.asyncio
264
+ async def test_missing_phone_cause_skip(settings: Settings) -> None:
265
+ repo = FakeRepository()
266
+ embeddings = FakeEmbeddings()
267
+ response = json.dumps({
268
+ "is_trip_ad": True,
269
+ "departure": "صنعاء",
270
+ "destination": "عدن",
271
+ "departure_date": "2026-07-15",
272
+ "departure_time": "morning",
273
+ "price": 5000,
274
+ "driver_phone": None,
275
+ })
276
+ provider = FakeProvider(response_content=response)
277
+
278
+ service = GroupMessageService(
279
+ repository=repo,
280
+ embeddings=embeddings,
281
+ provider=provider,
282
+ settings=settings,
283
+ )
284
+
285
+ inbound = _make_inbound(text="رحلة من صنعاء إلى عدن")
286
+ await service.handle_group_message(inbound)
287
+
288
+ assert len(repo.created_trips) == 0
289
+
290
+
291
+ @pytest.mark.asyncio
292
+ async def test_duplicate_group_message_is_deduplicated(settings: Settings) -> None:
293
+ repo = FakeRepository()
294
+ embeddings = FakeEmbeddings()
295
+ provider = FakeProvider(response_content=_trip_ad_response())
296
+
297
+ service = GroupMessageService(
298
+ repository=repo,
299
+ embeddings=embeddings,
300
+ provider=provider,
301
+ settings=settings,
302
+ )
303
+
304
+ inbound = _make_inbound(text="رحلة", message_id="wamid.dup.1")
305
+ await service.handle_group_message(inbound)
306
+ assert len(repo.created_trips) == 1
307
+
308
+ await service.handle_group_message(inbound)
309
+ assert len(repo.created_trips) == 1
310
+
311
+
312
+ @pytest.mark.asyncio
313
+ async def test_phone_normalization(settings: Settings) -> None:
314
+ repo = FakeRepository()
315
+ embeddings = FakeEmbeddings()
316
+ provider = FakeProvider(
317
+ response_content=_trip_ad_response(driver_phone="+967-71-234-5678")
318
+ )
319
+
320
+ service = GroupMessageService(
321
+ repository=repo,
322
+ embeddings=embeddings,
323
+ provider=provider,
324
+ settings=settings,
325
+ )
326
+
327
+ inbound = _make_inbound(text="رحلة")
328
+ await service.handle_group_message(inbound)
329
+
330
+ assert len(repo.customers_by_remote_jid) == 1
331
+ customer = list(repo.customers_by_remote_jid.values())[0]
332
+ assert customer["phone_number"] == "967712345678"
333
+
334
+
335
+ @pytest.mark.asyncio
336
+ async def test_invalid_json_response_cause_skip(settings: Settings) -> None:
337
+ repo = FakeRepository()
338
+ embeddings = FakeEmbeddings()
339
+ provider = FakeProvider(response_content="this is not json")
340
+
341
+ service = GroupMessageService(
342
+ repository=repo,
343
+ embeddings=embeddings,
344
+ provider=provider,
345
+ settings=settings,
346
+ )
347
+
348
+ inbound = _make_inbound(text="رحلة")
349
+ await service.handle_group_message(inbound)
350
+
351
+ assert len(repo.created_trips) == 0
352
+
353
+
354
+ @pytest.mark.asyncio
355
+ async def test_markdown_fenced_json_is_parsed(settings: Settings) -> None:
356
+ repo = FakeRepository()
357
+ embeddings = FakeEmbeddings()
358
+ fenced = f"```json\n{_trip_ad_response()}\n```"
359
+ provider = FakeProvider(response_content=fenced)
360
+
361
+ service = GroupMessageService(
362
+ repository=repo,
363
+ embeddings=embeddings,
364
+ provider=provider,
365
+ settings=settings,
366
+ )
367
+
368
+ inbound = _make_inbound(text="رحلة")
369
+ await service.handle_group_message(inbound)
370
+
371
+ assert len(repo.created_trips) == 1
372
+
373
+
374
+ @pytest.mark.asyncio
375
+ async def test_trip_ad_with_missing_car_type_uses_unknown(settings: Settings) -> None:
376
+ repo = FakeRepository()
377
+ embeddings = FakeEmbeddings()
378
+ provider = FakeProvider(
379
+ response_content=_trip_ad_response(car_type=None)
380
+ )
381
+
382
+ service = GroupMessageService(
383
+ repository=repo,
384
+ embeddings=embeddings,
385
+ provider=provider,
386
+ settings=settings,
387
+ )
388
+
389
+ inbound = _make_inbound(text="رحلة")
390
+ await service.handle_group_message(inbound)
391
+
392
+ assert len(repo.created_trips) == 1
393
+ cars = list(repo.driver_cars_by_driver.values())[0]
394
+ assert cars[0]["car_type"] == "غير معروف"
395
+
396
+
397
+ @pytest.mark.asyncio
398
+ async def test_trip_ad_with_missing_name_uses_none(settings: Settings) -> None:
399
+ repo = FakeRepository()
400
+ embeddings = FakeEmbeddings()
401
+ provider = FakeProvider(
402
+ response_content=_trip_ad_response(driver_name=None)
403
+ )
404
+
405
+ service = GroupMessageService(
406
+ repository=repo,
407
+ embeddings=embeddings,
408
+ provider=provider,
409
+ settings=settings,
410
+ )
411
+
412
+ inbound = _make_inbound(text="رحلة")
413
+ await service.handle_group_message(inbound)
414
+
415
+ assert len(repo.created_trips) == 1
416
+ customer = list(repo.customers_by_remote_jid.values())[0]
417
+ assert customer["name"] is None
tests/test_tools.py CHANGED
@@ -38,10 +38,12 @@ def trip(
38
  status="active",
39
  car_type="SUV",
40
  driver_phone_number=None,
 
41
  ):
42
- drivers = {"name": "Ali", "remoteJid": "967700000009"}
43
  if driver_phone_number is not None:
44
- drivers["customers"] = {"phone_number": driver_phone_number}
 
45
  return {
46
  "id": trip_id,
47
  "departure": departure,
 
38
  status="active",
39
  car_type="SUV",
40
  driver_phone_number=None,
41
+ registered=True,
42
  ):
43
+ customers: dict = {"registered": registered}
44
  if driver_phone_number is not None:
45
+ customers["phone_number"] = driver_phone_number
46
+ drivers = {"name": "Ali", "remoteJid": "967700000009", "customers": customers}
47
  return {
48
  "id": trip_id,
49
  "departure": departure,