1use alloc::collections::BTreeMap;
16use alloc::sync::Arc;
17use core::time::Duration;
18use std::sync::Mutex;
19
20use grabcli_core::cache::{Cache, CacheMode, MemoryCache};
21use grabcli_core::error::{Error as CoreError, ErrorKind};
22use grabcli_core::login::{
23 Channel, LoginDriver as CoreDriver, LoginRequest, LoginState, MemorySendBudget,
24};
25use grabcli_core::{Context, Session, SessionStore};
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29use super::api::{
30 Cart, CartOp, Fee, GrabBackend, GrabClient, GrabError, GrabResult, Health, Line, LoginDriver,
31 Menu, MenuItem, Merchant, Placed, Quote, SessionSlot, Step, Tracked,
32};
33use crate::{Log, Secret};
34
35const REFRESH_AHEAD: i64 = 6 * 3600;
37const TRACK_MAX: u32 = 3;
39
40fn map_err(e: &CoreError) -> GrabError {
42 match e.kind {
43 ErrorKind::AuthExpired | ErrorKind::AuthMissing => {
44 GrabError::NeedsRelogin(e.message.clone())
45 }
46 _ => GrabError::Failed(e.message.clone()),
47 }
48}
49
50fn to_core(e: &GrabError) -> CoreError {
51 match e {
52 GrabError::NeedsRelogin(m) => CoreError::new(ErrorKind::AuthMissing, m.clone()),
53 GrabError::Failed(m) => CoreError::new(ErrorKind::Io, m.clone()),
54 }
55}
56
57pub struct SlotStore(pub Arc<dyn SessionSlot>);
59
60#[async_trait::async_trait]
61impl SessionStore for SlotStore {
62 async fn load(&self) -> grabcli_core::Result<Option<Session>> {
63 match self.0.load().await.map_err(|e| to_core(&e))? {
64 None => Ok(None),
65 Some(v) => serde_json::from_value(v)
66 .map(Some)
67 .map_err(|_| CoreError::parse("the stored Grab session doesn't parse")),
68 }
69 }
70
71 async fn save(&self, session: &Session) -> grabcli_core::Result<()> {
72 let v = serde_json::to_value(session)
73 .map_err(|_| CoreError::parse("the Grab session doesn't serialize"))?;
74 self.0.save(&v).await.map_err(|e| to_core(&e))
75 }
76}
77
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80struct Kept {
81 cart: grabfood::Cart,
82 #[serde(default)]
83 merchant_name: String,
84 #[serde(default)]
86 items: BTreeMap<String, (String, i64)>,
87}
88
89impl Kept {
90 fn view(&self) -> Cart {
91 let lines: Vec<Line> = self
92 .cart
93 .items
94 .iter()
95 .map(|i| {
96 let (name, unit) = self.items.get(&i.item_id).cloned().unwrap_or_default();
97 Line {
98 item_id: i.item_id.clone(),
99 name,
100 quantity: u32::try_from(i.quantity).unwrap_or(0),
101 amount: unit.saturating_mul(i.quantity),
102 note: i.comment.clone(),
103 }
104 })
105 .collect();
106 Cart {
107 merchant_id: self.cart.merchant_id.clone(),
108 merchant_name: self.merchant_name.clone(),
109 currency: "PHP".into(),
110 subtotal: lines.iter().map(|l| l.amount).sum(),
111 lines,
112 }
113 }
114}
115
116fn eta(m: Option<i64>) -> Option<u32> {
117 m.and_then(|v| u32::try_from(v).ok())
118}
119
120fn merchant(m: &grabfood::discover::Merchant) -> Merchant {
121 Merchant {
122 id: m.id.clone(),
123 name: m.name.clone(),
124 cuisine: m.cuisines.join(", "),
125 eta_min: eta(m.eta_min),
126 distance_km: m.distance_km,
127 rating: m.rating,
128 promo: m.promo.clone().unwrap_or_default(),
129 open: m.open.unwrap_or(true),
130 }
131}
132
133fn menu_of(m: &grabfood::Menu) -> Menu {
134 Menu {
135 merchant: Merchant {
136 id: m.merchant_id.clone(),
137 name: m.name.clone(),
138 eta_min: eta(m.eta_min),
139 open: m.open,
140 ..Merchant::default()
141 },
142 currency: "PHP".into(),
143 items: m
144 .items
145 .iter()
146 .map(|i| MenuItem {
147 id: i.id.clone(),
148 name: i.name.clone(),
149 category: i.category.clone(),
150 price: i.price.minor(),
151 description: i.description.clone(),
152 available: i.available,
153 })
154 .collect(),
155 }
156}
157
158fn quote_of(q: &grabfood::Quote, kept: &Kept) -> Quote {
159 let mut fees: Vec<Fee> = Vec::new();
160 if q.delivery_after.minor() != 0 {
161 fees.push(Fee {
162 label: "Delivery".into(),
163 amount: q.delivery_after.minor(),
164 });
165 }
166 fees.extend(q.fees.iter().map(|f| Fee {
167 label: f.name.clone(),
168 amount: f.amount.minor(),
169 }));
170 let discount: i64 = q.promos.iter().map(|p| p.amount.minor().abs()).sum();
171 Quote {
172 quote_id: q.id.clone(),
173 merchant_name: q.merchant_name.clone(),
174 currency: "PHP".into(),
175 lines: kept.view().lines,
176 subtotal: q.subtotal.minor(),
177 fees,
178 discount,
179 voucher: q
180 .promos
181 .iter()
182 .map(|p| p.name.as_str())
183 .collect::<Vec<_>>()
184 .join(", "),
185 total: q.total_after.minor(),
186 eta_min: eta(q.eta_min),
187 payment_method: q.payment_type_id.clone(),
188 address_label: q
190 .address_detail
191 .clone()
192 .filter(|d| !d.trim().is_empty())
193 .unwrap_or_else(|| "saved address".into()),
194 }
195}
196
197fn tracked(t: &grabfood::Tracking) -> Tracked {
198 Tracked {
199 order_id: t.order_id.clone(),
200 merchant_name: t.merchant.name.clone().unwrap_or_default(),
201 status: t.human_state.clone(),
202 eta_min: eta(t.eta.max_minutes.or(t.eta.min_minutes)),
203 driver: t
204 .driver
205 .as_ref()
206 .and_then(|d| d.name.clone())
207 .unwrap_or_default(),
208 }
209}
210
211pub struct Real {
213 config: grabcli_core::Config,
214 caches: Mutex<BTreeMap<String, Arc<MemoryCache>>>,
215 log: Option<Log>,
216}
217
218impl Real {
219 pub fn new(config: grabcli_core::Config) -> Self {
223 Self {
224 config,
225 caches: Mutex::default(),
226 log: None,
227 }
228 }
229
230 #[must_use]
232 pub fn with_log(mut self, log: Log) -> Self {
233 self.log = Some(log);
234 self
235 }
236
237 fn cache(&self, org: &str) -> Arc<MemoryCache> {
238 Arc::clone(
239 crate::util::lock(&self.caches)
240 .entry(org.to_owned())
241 .or_default(),
242 )
243 }
244}
245
246impl GrabBackend for Real {
247 fn client(&self, session: Arc<dyn SessionSlot>) -> Arc<dyn GrabClient> {
248 let cache = self.cache(session.id());
249 Arc::new(RealClient {
250 config: self.config.clone(),
251 cache,
252 slot: session,
253 log: self.log.clone(),
254 })
255 }
256
257 fn login(
258 &self,
259 cdp_url: &str,
260 previous: Option<Value>,
261 ) -> Result<Box<dyn LoginDriver>, String> {
262 if cdp_url.is_empty() {
263 return Err("no CDP URL: no browser to log in with".into());
264 }
265 Ok(Box::new(RealLogin {
266 cdp: cdp_url.to_owned(),
267 previous: previous.and_then(|v| serde_json::from_value(v).ok()),
268 inner: None,
269 }))
270 }
271}
272
273struct RealClient {
274 config: grabcli_core::Config,
275 cache: Arc<MemoryCache>,
276 slot: Arc<dyn SessionSlot>,
277 log: Option<Log>,
278}
279
280impl RealClient {
281 async fn ctx(&self, browse: bool) -> GrabResult<Context> {
282 let store: Arc<dyn SessionStore> = Arc::new(SlotStore(Arc::clone(&self.slot)));
283 let cache: Arc<dyn Cache> = Arc::clone(&self.cache) as Arc<dyn Cache>;
284 let mut ctx = grabfood::context(self.config.clone(), store, cache)
285 .await
286 .map_err(|e| map_err(&e))?;
287 if browse {
288 ctx.set_cache_mode(CacheMode::Normal);
289 }
290 Ok(ctx)
291 }
292
293 async fn kept(&self) -> GrabResult<Kept> {
294 Ok(self
295 .slot
296 .load_cart()
297 .await?
298 .and_then(|v| serde_json::from_value(v).ok())
299 .unwrap_or_default())
300 }
301
302 async fn keep(&self, k: &Kept) -> GrabResult<()> {
303 if k.cart.is_empty() {
304 return self.slot.save_cart(None).await;
305 }
306 let v = serde_json::to_value(k).map_err(|e| GrabError::Failed(e.to_string()))?;
307 self.slot.save_cart(Some(&v)).await
308 }
309}
310
311#[async_trait::async_trait]
312impl GrabClient for RealClient {
313 async fn search(&self, query: &str, limit: usize) -> GrabResult<Vec<Merchant>> {
314 let mut ctx = self.ctx(true).await?;
315 let opts = grabfood::discover::ListOpts {
316 limit: Some(limit),
317 pages: 1,
318 ..Default::default()
319 };
320 let l = grabfood::browse::search(&mut ctx, query, &opts, false, false, None)
321 .await
322 .map_err(|e| map_err(&e))?;
323 Ok(l.merchants.iter().take(limit).map(merchant).collect())
324 }
325
326 async fn menu(&self, merchant_id: &str) -> GrabResult<Menu> {
327 let mut ctx = self.ctx(true).await?;
328 let m = grabfood::menu(&mut ctx, merchant_id, grabfood::MenuSource::Cached)
329 .await
330 .map_err(|e| map_err(&e))?;
331 Ok(menu_of(&m))
332 }
333
334 async fn cart(&self, op: &CartOp) -> GrabResult<Cart> {
335 let mut k = self.kept().await?;
336 match op {
337 CartOp::Show => return Ok(k.view()),
338 CartOp::Clear => k = Kept::default(),
339 CartOp::Remove { item_id } => {
340 grabfood::cart_remove(&mut k.cart, item_id).map_err(|e| map_err(&e))?;
341 if k.cart.is_empty() {
342 k = Kept::default();
343 }
344 }
345 CartOp::Add {
346 merchant_id,
347 item_id,
348 quantity,
349 note,
350 } => {
351 let mut ctx = self.ctx(true).await?;
352 grabfood::cart_add(
353 &mut ctx,
354 &mut k.cart,
355 merchant_id,
356 item_id,
357 i64::from(*quantity),
358 &[],
359 note.clone(),
360 )
361 .await
362 .map_err(|e| map_err(&e))?;
363 let m = grabfood::menu(&mut ctx, merchant_id, grabfood::MenuSource::Cached)
365 .await
366 .map_err(|e| map_err(&e))?;
367 k.merchant_name.clone_from(&m.name);
368 if let Some(i) = m.item(item_id) {
369 k.items
370 .insert(item_id.clone(), (i.name.clone(), i.price.minor()));
371 }
372 }
373 }
374 self.keep(&k).await?;
375 Ok(k.view())
376 }
377
378 async fn quote(&self) -> GrabResult<Quote> {
379 let k = self.kept().await?;
380 if k.cart.is_empty() {
381 return Err(GrabError::Failed("the cart is empty".into()));
382 }
383 let mut ctx = self.ctx(false).await?;
384 let q = grabfood::quote(&mut ctx, &k.cart, None, None)
385 .await
386 .map_err(|e| map_err(&e))?;
387 Ok(quote_of(&q, &k))
388 }
389
390 async fn place_order(&self, quote_id: &str) -> GrabResult<Placed> {
391 let mut k = self.kept().await?;
392 let mut ctx = self.ctx(false).await?;
393 let p = grabfood::place_order(&mut ctx, &mut k.cart, quote_id, None)
395 .await
396 .map_err(|e| map_err(&e))?;
397 if let Err(e) = self.slot.save_cart(None).await
398 && let Some(l) = &self.log
399 {
400 l(&format!("{}: emptying the cart: {e}", self.slot.id()));
401 }
402 Ok(Placed {
403 order_id: p.order_id,
404 eta_min: eta(p.quote.eta_min),
405 })
406 }
407
408 async fn track(&self, order_id: Option<&str>) -> GrabResult<Vec<Tracked>> {
409 let mut ctx = self.ctx(false).await?;
410 let ids: Vec<String> = match order_id {
411 Some(id) => vec![id.to_owned()],
412 None => grabfood::orders(&mut ctx, true, TRACK_MAX)
413 .await
414 .map_err(|e| map_err(&e))?
415 .into_iter()
416 .map(|o| o.order_id)
417 .collect(),
418 };
419 let mut out = Vec::new();
420 for id in ids {
421 let t = grabfood::track(&mut ctx, &id)
422 .await
423 .map_err(|e| map_err(&e))?;
424 out.push(tracked(&t));
425 }
426 Ok(out)
427 }
428
429 async fn refresh(&self, liveness: bool) -> GrabResult<Health> {
430 let mut ctx = self.ctx(false).await?;
431 let now = crate::util::now_s();
432 let due = ctx
433 .session()
434 .gfc_session_exp()
435 .is_none_or(|e| e - now < REFRESH_AHEAD);
436 if due {
437 ctx.refresh_session().await.map_err(|e| map_err(&e))?;
438 }
439 if liveness {
440 grabfood::orders(&mut ctx, true, 1)
442 .await
443 .map_err(|e| map_err(&e))?;
444 }
445 Ok(Health {
446 display_name: None,
448 sso_expires_at: ctx.session().sso_expires_at(),
449 })
450 }
451}
452
453struct RealLogin {
455 cdp: String,
456 previous: Option<Session>,
457 inner: Option<CoreDriver>,
458}
459
460fn masked(s: &LoginState) -> Option<String> {
463 match s {
464 LoginState::AwaitingCode { phone_masked, .. }
465 | LoginState::AwaitingApproval { phone_masked, .. } => Some(phone_masked.clone()),
466 LoginState::Done(_) | LoginState::Failed(_) => None,
467 }
468}
469
470impl RealLogin {
471 fn step(&mut self, name: Option<String>) -> Step {
472 let Some(d) = self.inner.as_mut() else {
473 return Step::Failed("the login didn't start".into());
474 };
475 match d.state() {
476 LoginState::AwaitingCode { channel, error, .. } => match error {
477 Some(e) => Step::Failed(e.clone()),
478 None => Step::AwaitingCode {
479 channel: channel.clone().unwrap_or_default().to_ascii_lowercase(),
480 },
481 },
482 LoginState::AwaitingApproval { .. } => Step::AwaitingApproval,
483 LoginState::Failed(m) => Step::Failed(m.clone()),
484 LoginState::Done(s) => {
485 let sso = s.sso_expires_at();
486 match serde_json::to_value(s) {
487 Ok(session) => Step::Done {
488 session,
489 display_name: name.unwrap_or_else(|| "Grab".into()),
490 sso_expires_at: sso,
491 },
492 Err(_) => Step::Failed("the Grab session doesn't serialize".into()),
493 }
494 }
495 }
496 }
497
498 fn name(&self) -> Option<String> {
499 self.inner.as_ref().and_then(|d| masked(d.state()))
500 }
501}
502
503impl LoginDriver for RealLogin {
504 fn start(&mut self, phone: &Secret) -> Step {
505 let budget = MemorySendBudget::default();
508 let req = LoginRequest {
509 cdp_url: &self.cdp,
510 phone: phone.expose(),
511 channel: Channel::Sms,
512 previous: self.previous.clone(),
513 copy_browser_address: false,
514 };
515 self.inner = Some(crate::util::block_on(CoreDriver::start(req, &budget)));
516 let n = self.name();
517 self.step(n)
518 }
519
520 fn submit_code(&mut self, code: &Secret) -> Step {
521 let n = self.name();
522 if let Some(d) = self.inner.as_mut() {
523 crate::util::block_on(d.submit_code(code.expose()));
524 }
525 self.step(n)
526 }
527
528 fn wait(&mut self, timeout: Duration) -> Step {
529 let n = self.name();
530 if let Some(d) = self.inner.as_mut() {
531 crate::util::block_on(d.poll(timeout));
532 }
533 self.step(n)
534 }
535
536 fn cancel(&mut self) {
537 if let Some(d) = self.inner.as_mut() {
538 crate::util::block_on(d.cancel());
539 }
540 }
541}
542
543impl Drop for RealLogin {
544 fn drop(&mut self) {
545 if self
546 .inner
547 .as_ref()
548 .is_some_and(|d| !d.state().is_terminal())
549 {
550 self.cancel();
551 }
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 #![allow(clippy::expect_used, clippy::unwrap_used)]
558
559 use grabcli_core::money::Money;
560 use serde_json::json;
561
562 use super::*;
563 use crate::MemorySlot;
564
565 fn slot() -> Arc<MemorySlot> {
566 Arc::new(MemorySlot::new("acct-1", Some(json!({}))))
567 }
568
569 #[test]
570 fn errors_map_to_relogin_or_failed() {
571 let e = CoreError::new(ErrorKind::AuthExpired, "401");
572 assert_eq!(map_err(&e), GrabError::NeedsRelogin("401".into()));
573 let e = CoreError::new(ErrorKind::QuoteChanged, "changed");
574 assert_eq!(map_err(&e), GrabError::Failed("changed".into()));
575 assert_eq!(
576 to_core(&GrabError::NeedsRelogin("x".into())).kind,
577 ErrorKind::AuthMissing
578 );
579 }
580
581 #[test]
582 fn the_session_round_trips_through_the_slot() {
583 let s = slot();
584 let st = SlotStore(Arc::clone(&s) as Arc<dyn SessionSlot>);
585 let mut sess = crate::util::block_on(st.load())
586 .expect("load")
587 .expect("some");
588 sess.payment_type_id = Some("CASH".into());
589 crate::util::block_on(st.save(&sess)).expect("save");
590 assert_eq!(s.session().expect("session")["payment_type_id"], "CASH");
591 let back = crate::util::block_on(st.load())
592 .expect("load")
593 .expect("some");
594 assert_eq!(back.payment_type_id.as_deref(), Some("CASH"));
595 }
596
597 #[test]
598 fn the_cart_is_kept_beside_the_session_without_grab() {
599 let s = slot();
600 let mut k = Kept {
601 merchant_name: "Sample Kitchen".into(),
602 ..Kept::default()
603 };
604 k.cart.merchant_id = "m1".into();
605 for (id, q) in [("i1", 2), ("i2", 1)] {
606 k.cart.items.push(grabfood::CartItem {
607 item_id: id.into(),
608 quantity: q,
609 modifiers: Vec::new(),
610 comment: String::new(),
611 });
612 }
613 k.items
614 .insert("i1".into(), ("Fried Chicken".into(), 10_000));
615 k.items.insert("i2".into(), ("Spaghetti".into(), 6_500));
616 let c = RealClient {
617 config: grabcli_core::Config::default(),
618 cache: Arc::default(),
619 slot: Arc::clone(&s) as Arc<dyn SessionSlot>,
620 log: None,
621 };
622 crate::util::block_on(c.keep(&k)).expect("keep");
623 let v = crate::util::block_on(c.cart(&CartOp::Show)).expect("show");
625 assert_eq!(v.subtotal, 26_500);
626 assert_eq!(v.lines[0].name, "Fried Chicken");
627 assert_eq!(v.lines[0].amount, 20_000);
628 let v = crate::util::block_on(c.cart(&CartOp::Remove {
629 item_id: "i1".into(),
630 }))
631 .expect("remove");
632 assert_eq!(v.subtotal, 6_500);
633 assert!(s.cart().is_some());
634 assert!(s.session().is_some(), "the session stays");
635 let v = crate::util::block_on(c.cart(&CartOp::Clear)).expect("clear");
636 assert!(v.lines.is_empty());
637 assert!(s.cart().is_none());
638 assert!(
639 crate::util::block_on(c.quote()).is_err(),
640 "an empty cart isn't quoted"
641 );
642 }
643
644 #[test]
645 fn a_quote_maps_fees_promos_and_a_label_only() {
646 let q = grabfood::Quote {
647 id: "q-1".into(),
648 merchant_name: "Sample Kitchen".into(),
649 subtotal: Money(26_500),
650 delivery_after: Money(4_900),
651 fees: vec![grabfood::quote::Fee {
652 name: "Small order fee".into(),
653 amount: Money(1_000),
654 }],
655 promos: vec![grabfood::quote::Promo {
656 name: "SAVE50".into(),
657 amount: Money(-5_000),
658 }],
659 total_after: Money(27_400),
660 eta_min: Some(35),
661 payment_type_id: "CASH".into(),
662 address: "1 Example St".into(),
663 address_detail: Some("Home".into()),
664 ..grabfood::Quote::default()
665 };
666 let out = quote_of(&q, &Kept::default());
667 assert_eq!(out.quote_id, "q-1");
668 assert_eq!(out.fees.len(), 2);
669 assert_eq!(out.fees[0].amount, 4_900);
670 assert_eq!(out.discount, 5_000);
671 assert_eq!(out.voucher, "SAVE50");
672 assert_eq!(out.total, 27_400);
673 assert_eq!(out.address_label, "Home");
674 assert!(!format!("{out:?}").contains("Example St"));
675 }
676
677 #[test]
678 fn no_browser_no_login() {
679 let r = Real::new(grabcli_core::Config::default());
680 assert!(r.login("", None).is_err());
681 assert!(r.login("http://127.0.0.1:9", None).is_ok());
682 }
683}