Skip to main content

grabcli_service/
real.rs

1//! grabcli's library as the [`GrabBackend`]: `grabfood` for the client,
2//! `grabcli_core::login::LoginDriver` for the login.
3//!
4//! - The session lives wherever the [`SessionSlot`] keeps it: [`SlotStore`]
5//!   is grabcli's `SessionStore` over the slot, so every refresh grabcli
6//!   makes (SSO, hydra) is saved there. Nothing is read from or written to
7//!   grabcli's own config or state directories.
8//! - The cart is grabcli's [`grabfood::Cart`] plus the names and unit
9//!   prices seen when adding, kept beside the session (unsealed: ids and
10//!   prices only).
11//! - The response cache is in memory, one per account, for browsing only
12//!   (grabfood never reads it on the quote and order path).
13//! - Each call builds a fresh `Context` from the stored session.
14
15use 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
35/// Refresh the web session when it ends within this (seconds).
36const REFRESH_AHEAD: i64 = 6 * 3600;
37/// Ongoing orders [`GrabClient::track`] looks at without an id.
38const TRACK_MAX: u32 = 3;
39
40/// grabcli's error, ours: a dead or missing login needs a re-login.
41fn 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
57/// grabcli's `SessionStore` over an account's slot.
58pub 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/// The kept cart: grabcli's, with what the tools show of it.
79#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80struct Kept {
81    cart: grabfood::Cart,
82    #[serde(default)]
83    merchant_name: String,
84    /// Item id: (name, unit price in centavos).
85    #[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        // A label only: the street stays with Grab.
189        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
211/// grabcli, for every account.
212pub struct Real {
213    config: grabcli_core::Config,
214    caches: Mutex<BTreeMap<String, Arc<MemoryCache>>>,
215    log: Option<Log>,
216}
217
218impl Real {
219    /// grabcli on `config` (its `allow_ordering` gates
220    /// [`GrabClient::place_order`]; its `cdp` is the browser hydra is
221    /// minted in, when needed).
222    pub fn new(config: grabcli_core::Config) -> Self {
223        Self {
224            config,
225            caches: Mutex::default(),
226            log: None,
227        }
228    }
229
230    /// Send diagnostics (no secrets) to `log`.
231    #[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                // The same (cached) menu, for the name and the price.
364                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        // grabfood re-quotes and refuses a different quote id itself too.
394        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            // A signed-in call: the ongoing orders (none is fine).
441            grabfood::orders(&mut ctx, true, 1)
442                .await
443                .map_err(|e| map_err(&e))?;
444        }
445        Ok(Health {
446            // grabcli has no account-name call; the name stays as connected.
447            display_name: None,
448            sso_expires_at: ctx.session().sso_expires_at(),
449        })
450    }
451}
452
453/// A login on grabcli's step driver, from the login's own thread.
454struct RealLogin {
455    cdp: String,
456    previous: Option<Session>,
457    inner: Option<CoreDriver>,
458}
459
460/// The masked phone (`+1••••••••00`) the account is shown by: grabcli
461/// has no account-name call.
462fn 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        // `Logins` keeps the per-account send budget; grabcli's own is per
506        // login.
507        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        // Show and remove never call Grab.
624        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}