1use std::sync::Mutex;
33use std::time::Duration;
34
35use async_trait::async_trait;
36use serde::{Deserialize, Serialize};
37use serde_json::{Value, json};
38
39use crate::cdp::{self, Browser, PageInfo};
40use crate::error::{Error, ErrorKind, Result};
41use crate::jwt;
42use crate::session::{LOCATION_COOKIE, Session};
43use crate::sso;
44
45pub const LOGIN_START_URL: &str = sso::FOOD_LOGIN;
47pub const PENDING_TTL_SECS: i64 = 10 * 60;
49pub const SEND_COOLDOWN_SECS: i64 = 60;
51pub const MAX_SENDS_PER_HOUR: usize = 3;
53
54const CALLING_CODES: &[&str] = &["855", "60", "62", "63", "65", "66", "84", "95"];
58
59pub fn split_phone(phone: &str) -> Result<(String, String)> {
62 let cleaned: String = phone
63 .chars()
64 .filter(|c| !matches!(c, ' ' | '-' | '.' | '(' | ')'))
65 .collect();
66 let digits = cleaned.strip_prefix('+').ok_or_else(|| {
67 Error::invalid("phone must be in international form, e.g. +63 9XX XXX XXXX")
68 })?;
69 if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
70 return Err(Error::invalid("phone must contain only digits after the +"));
71 }
72 let cc = CALLING_CODES
73 .iter()
74 .find(|cc| digits.starts_with(**cc))
75 .ok_or_else(|| Error::invalid("phone country code is not a Grab market"))?;
76 let national = digits[cc.len()..].trim_start_matches('0').to_string();
77 if !(6..=12).contains(&national.len()) {
78 return Err(Error::invalid("phone number has the wrong length"));
79 }
80 Ok(((*cc).to_string(), national))
81}
82
83pub fn mask_phone(cc: &str, national: &str) -> String {
85 let n = national.len();
86 let keep = n.min(2);
87 format!("+{cc}{}{}", "•".repeat(n - keep), &national[n - keep..])
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum Channel {
96 App,
98 Sms,
100 Whatsapp,
102}
103
104impl Channel {
105 pub fn parse(s: &str) -> Result<Self> {
106 match s.to_ascii_lowercase().as_str() {
107 "app" | "push" => Ok(Self::App),
108 "sms" => Ok(Self::Sms),
109 "whatsapp" | "wa" => Ok(Self::Whatsapp),
110 _ => Err(Error::invalid("--channel must be app, sms or whatsapp")),
111 }
112 }
113
114 pub fn as_str(self) -> &'static str {
115 match self {
116 Self::App => "app",
117 Self::Sms => "sms",
118 Self::Whatsapp => "whatsapp",
119 }
120 }
121
122 pub fn describe(self) -> &'static str {
124 match self {
125 Self::App => "one approval request in the Grab app",
126 Self::Sms | Self::Whatsapp => {
127 "one OTP code (Grab chooses SMS or WhatsApp delivery; the CLI never resends to switch)"
128 }
129 }
130 }
131
132 pub fn challenge_types(self) -> &'static [&'static str] {
135 match self {
136 Self::App => &["PUSH_V2", "PUSH"],
137 Self::Sms | Self::Whatsapp => &["CHANNEL_OPTIMIZED_PHONE_OTP", "SMS_OTP"],
138 }
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum PendingKind {
148 OtpSent,
150 ApprovalPending,
152}
153
154impl PendingKind {
155 pub fn as_str(self) -> &'static str {
156 match self {
157 Self::OtpSent => "otp_sent",
158 Self::ApprovalPending => "approval_pending",
159 }
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct PendingLogin {
167 pub kind: PendingKind,
168 pub cdp_base: String,
169 pub browser_context_id: String,
170 pub target_id: String,
171 pub created_at: i64,
172 pub expires_at: i64,
173 pub phone_masked: String,
174 pub channel_requested: Channel,
175 #[serde(default)]
177 pub channel_sent: Option<String>,
178 #[serde(default = "yes")]
182 pub copy_browser_address: bool,
183}
184
185fn yes() -> bool {
186 true
187}
188
189impl PendingLogin {
190 pub fn is_expired(&self, now: i64) -> bool {
191 self.expires_at <= now
192 }
193
194 pub fn next_command(&self) -> &'static str {
197 match self.kind {
198 PendingKind::OtpSent => "grab auth login --otp <code>",
199 PendingKind::ApprovalPending => "grab auth login --poll",
200 }
201 }
202
203 pub fn summary(&self, now: i64) -> Value {
204 json!({
205 "kind": self.kind.as_str(),
206 "phone": self.phone_masked,
207 "channel_requested": self.channel_requested.as_str(),
208 "channel_sent": self.channel_sent,
209 "expires_in_secs": (self.expires_at - now).max(0),
210 "expired": self.is_expired(now),
211 })
212 }
213}
214
215pub fn pending_json(p: &PendingLogin, now: i64) -> Value {
217 let mut v = p.summary(now);
218 v["expires_at"] = json!(p.expires_at);
219 v["next"] = json!(p.next_command());
220 v
221}
222
223#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229pub struct SendLog {
230 pub sends: Vec<i64>,
231}
232
233impl SendLog {
234 fn recent(&self, now: i64) -> impl Iterator<Item = i64> + '_ {
235 self.sends.iter().copied().filter(move |t| now - t < 3600)
236 }
237
238 pub fn sends_last_hour(&self, now: i64) -> usize {
239 self.recent(now).count()
240 }
241
242 pub fn wait_secs(&self, now: i64) -> i64 {
244 let mut recent: Vec<i64> = self.recent(now).collect();
245 recent.sort_unstable();
246 let cooldown = recent
247 .last()
248 .map_or(0, |last| last + SEND_COOLDOWN_SECS - now);
249 let hourly = if recent.len() >= MAX_SENDS_PER_HOUR {
250 recent[recent.len() - MAX_SENDS_PER_HOUR] + 3600 - now
251 } else {
252 0
253 };
254 cooldown.max(hourly).max(0)
255 }
256
257 pub fn check(&self, now: i64) -> Result<()> {
260 let wait = self.wait_secs(now);
261 if wait == 0 {
262 return Ok(());
263 }
264 let why = if self.sends_last_hour(now) >= MAX_SENDS_PER_HOUR {
265 format!("{MAX_SENDS_PER_HOUR} login challenges were already sent in the last hour")
266 } else {
267 format!("a login challenge was sent less than {SEND_COOLDOWN_SECS}s ago")
268 };
269 Err(Error::new(
270 ErrorKind::RateLimited,
271 format!("{why}; nothing was sent. Try again in {wait}s."),
272 )
273 .with_details(json!({"retry_after_secs": wait})))
274 }
275
276 pub fn record(&mut self, now: i64) {
279 self.sends.retain(|t| now - t < 3600);
280 self.sends.push(now);
281 }
282}
283
284#[async_trait]
288pub trait SendBudget: Send + Sync {
289 async fn check(&self, now: i64) -> Result<()>;
291 async fn record(&self, now: i64) -> Result<()>;
294}
295
296#[derive(Debug, Default)]
298pub struct MemorySendBudget {
299 pub log: Mutex<SendLog>,
300}
301
302impl MemorySendBudget {
303 fn lock(&self) -> std::sync::MutexGuard<'_, SendLog> {
304 self.log
305 .lock()
306 .unwrap_or_else(std::sync::PoisonError::into_inner)
307 }
308
309 pub fn snapshot(&self) -> SendLog {
311 self.lock().clone()
312 }
313}
314
315#[async_trait]
316impl SendBudget for MemorySendBudget {
317 async fn check(&self, now: i64) -> Result<()> {
318 self.lock().check(now)
319 }
320
321 async fn record(&self, now: i64) -> Result<()> {
322 self.lock().record(now);
323 Ok(())
324 }
325}
326
327pub fn order_tracking_tabs(pages: &[PageInfo]) -> Vec<String> {
336 pages
337 .iter()
338 .filter_map(|p| {
339 let u = reqwest::Url::parse(&p.url).ok()?;
340 let path = u.path();
341 (u.host_str() == Some("food.grab.com")
342 && (path.contains("/order/") || path.contains("/track")))
343 .then(|| format!("food.grab.com{path}"))
344 })
345 .collect()
346}
347
348pub fn tracking_tab_warning(tabs: &[String]) -> Option<String> {
349 if tabs.is_empty() {
350 return None;
351 }
352 Some(format!(
353 "{} food.grab.com order-tracking tab(s) may be open in the browser ({}). When the \
354 browser's food session lapses such tabs poll /track, get 401 and call Grab's logout \
355 every ~16s, which logs that browser out. If they are open, close them (grabcli won't \
356 touch other tabs).",
357 tabs.len(),
358 tabs.join(", ")
359 ))
360}
361
362#[derive(Debug, Clone, PartialEq, Eq)]
366pub enum Screen {
367 Loading,
369 Phone,
371 Options(Vec<String>),
373 Otp {
375 channel: Option<String>,
376 },
377 OtpRejected,
379 Approval,
381 Done,
383 StepUp(String),
385 Problem(String),
387 Unknown(String),
388}
389
390pub const PROBE_JS: &str = r#"(() => {
393 const root = document.getElementById('root');
394 const options = [...document.querySelectorAll('[role=button] img[alt^="Proceed to "]')]
395 .map(i => i.getAttribute('alt').slice('Proceed to '.length));
396 const captcha = [...document.querySelectorAll('iframe')].some(f =>
397 /recaptcha\/(api2|enterprise)\/bframe/.test(f.src || '') && f.getBoundingClientRect().height > 100);
398 return {
399 host: location.host, path: location.pathname, ready: document.readyState,
400 has_phone: !!document.getElementById('Phone'), options, captcha,
401 text: ((root && root.innerText) || (document.body && document.body.innerText) || '').slice(0, 3000),
402 };
403})()"#;
404
405const PROBLEMS: &[&str] = &[
406 "Account temporarily blocked",
407 "temporarily locked",
408 "You've tried too many times",
409 "Try again later",
410 "Too many attempts",
411 "Unable to resend code",
412 "OTP challenge expired",
413 "Request rejected",
414 "QR Code Expired",
415 "Account Not Found",
416 "Oops, something went wrong",
417];
418
419pub fn classify_screen(p: &Value) -> Screen {
420 let s = |k: &str| p.get(k).and_then(Value::as_str).unwrap_or_default();
421 let host = s("host");
422 let path = s("path");
423 let text = s("text");
424 if host == "food.grab.com" && !path.starts_with("/auth/") {
425 return Screen::Done;
426 }
427 if host != "weblogin.grab.com" {
428 return if host.is_empty() || host == "food.grab.com" {
429 Screen::Loading
430 } else {
431 Screen::Unknown(host.to_string())
432 };
433 }
434 if p.get("captcha").and_then(Value::as_bool) == Some(true) {
435 return Screen::StepUp("recaptcha".into());
436 }
437 if let Some(pr) = PROBLEMS.iter().find(|w| text.contains(**w)) {
438 return Screen::Problem((*pr).to_string());
439 }
440 if text.contains("Incorrect code") || text.contains("no longer valid") {
441 return Screen::OtpRejected;
442 }
443 let segs: Vec<&str> = path.split('/').filter(|x| !x.is_empty()).collect();
444 let has = |x: &str| segs.contains(&x);
445 let otp_channel = || {
446 ["SMS", "WhatsApp", "Viber", "Zalo"]
447 .iter()
448 .find(|c| text.contains(&format!("Check your {c}")))
449 .map(|c| (*c).to_string())
450 };
451 if has("optimised-sms-otp") || has("sms-otp") || has("email-otp") {
452 return Screen::Otp {
453 channel: otp_channel(),
454 };
455 }
456 if has("push-v2") || has("push") {
457 return Screen::Approval;
458 }
459 if let Some(seg) = [
460 "recaptcha",
461 "arkose",
462 "web-selfie",
463 "passkey",
464 "password",
465 "pin",
466 ]
467 .iter()
468 .find(|x| has(x))
469 {
470 return Screen::StepUp((*seg).to_string());
471 }
472 let options: Vec<String> = p
473 .get("options")
474 .and_then(Value::as_array)
475 .map(|a| {
476 a.iter()
477 .filter_map(Value::as_str)
478 .map(String::from)
479 .collect()
480 })
481 .unwrap_or_default();
482 if has("challenge-options") || (!options.is_empty() && text.contains("Choose")) {
483 return Screen::Options(options);
484 }
485 if text.contains("Approve in mobile app") || text.contains("App push notification sent") {
487 return Screen::Approval;
488 }
489 if text.contains("Check your") || text.contains("Enter verification code") {
490 return Screen::Otp {
491 channel: otp_channel(),
492 };
493 }
494 if p.get("has_phone").and_then(Value::as_bool) == Some(true) {
495 return Screen::Phone;
496 }
497 Screen::Loading
498}
499
500pub fn pick_option(channel: Channel, offered: &[String]) -> Option<&'static str> {
502 channel
503 .challenge_types()
504 .iter()
505 .copied()
506 .find(|t| offered.iter().any(|o| o == t))
507}
508
509pub fn dry_run_plan(
513 phone_masked: &str,
514 channel: Channel,
515 cdp_base: &str,
516 log: &SendLog,
517 now: i64,
518) -> Value {
519 json!({
520 "kind": "dry_run",
521 "dry_run": true,
522 "sent": false,
523 "phone": phone_masked,
524 "channel": channel.as_str(),
525 "cdp": cdp_base,
526 "would_send": channel.describe(),
527 "steps": [
528 "try a silent SSO refresh first (never sends anything); stop if it works",
529 "open food.grab.com/auth/login in a NEW isolated browser context (no shared cookies, other tabs untouched)",
530 "type the phone number, keep \"Remember me on this device for 60 days\" ticked, click Continue",
531 format!("if Grab offers a choice, pick {} (once; never resend or retry)", channel.as_str()),
532 format!("stop and keep the login tab alive for {} min; finish with `grab auth login --otp <code>` or `grab auth login --poll`", PENDING_TTL_SECS / 60),
533 ],
534 "sends_last_hour": log.sends_last_hour(now),
535 "max_sends_per_hour": MAX_SENDS_PER_HOUR,
536 "next_send_allowed_in_secs": log.wait_secs(now),
537 })
538}
539
540fn rect_js(find_expr: &str) -> String {
545 format!(
546 r#"(() => {{ const el = {find_expr}; if (!el) return null;
547 el.scrollIntoView({{block: 'center'}}); const r = el.getBoundingClientRect();
548 if (r.width === 0 || r.height === 0) return null;
549 return {{x: r.left + r.width / 2, y: r.top + r.height / 2, disabled: !!el.disabled}}; }})()"#
550 )
551}
552
553const CONTINUE_BTN: &str = "[...document.querySelectorAll('button.dui-btn-primary, button')].find(b => b.innerText.trim() === 'Continue')";
554const PHONE_INPUT: &str = "document.getElementById('Phone')";
555const REMEMBER_LABEL: &str =
556 "[...document.querySelectorAll('label')].find(l => /Remember me/.test(l.innerText))";
557const REMEMBER_STATE: &str = r#"(() => { const l = [...document.querySelectorAll('label')].find(l => /Remember me/.test(l.innerText));
558 if (!l) return 'absent'; const i = l.querySelector('input[type=checkbox]');
559 return (i && i.checked) || /checkbox-wrapper-checked/.test(l.className) ? 'checked' : 'unchecked'; })()"#;
560const OTP_INPUT: &str = "[...document.querySelectorAll('#root input')].filter(i => i.id !== 'Phone' && !['checkbox','search','hidden'].includes(i.type) && i.offsetParent !== null).find(i => !i.value) || [...document.querySelectorAll('#root input')].find(i => i.id !== 'Phone' && !['checkbox','search','hidden'].includes(i.type) && i.offsetParent !== null)";
562
563struct Tab {
564 browser: Browser,
565 session: String,
566}
567
568impl Tab {
569 async fn probe(&mut self) -> Result<Screen> {
570 self.browser.drain_events(Duration::ZERO).await.ok();
572 match self.browser.eval(&self.session, PROBE_JS).await {
573 Ok(v) => Ok(classify_screen(&v)),
574 Err(_) => Ok(Screen::Loading),
576 }
577 }
578
579 async fn wait_for(
581 &mut self,
582 timeout: Duration,
583 done: impl Fn(&Screen) -> bool,
584 ) -> Result<Screen> {
585 let deadline = tokio::time::Instant::now() + timeout;
586 loop {
587 let s = self.probe().await?;
588 if done(&s) || tokio::time::Instant::now() >= deadline {
589 return Ok(s);
590 }
591 tokio::time::sleep(Duration::from_millis(700)).await;
592 }
593 }
594
595 async fn rect(&mut self, find_expr: &str) -> Result<Option<(f64, f64, bool)>> {
596 let v = self
597 .browser
598 .eval(&self.session, &rect_js(find_expr))
599 .await?;
600 Ok(
601 match (
602 v.get("x").and_then(Value::as_f64),
603 v.get("y").and_then(Value::as_f64),
604 ) {
605 (Some(x), Some(y)) => Some((
606 x,
607 y,
608 v.get("disabled").and_then(Value::as_bool).unwrap_or(false),
609 )),
610 _ => None,
611 },
612 )
613 }
614
615 async fn click(&mut self, find_expr: &str, what: &str) -> Result<()> {
616 let (x, y, _) = self
617 .rect(find_expr)
618 .await?
619 .ok_or_else(|| Error::new(ErrorKind::Api, format!("login page: no {what} found")))?;
620 self.browser.click_at(&self.session, x, y).await
621 }
622}
623
624#[derive(Clone, Copy)]
626pub struct StartOpts<'a> {
627 pub cdp_base: &'a str,
628 pub phone: &'a str,
629 pub channel: Channel,
630 pub copy_browser_address: bool,
632}
633
634fn page_error(s: &Screen) -> Error {
635 match s {
636 Screen::StepUp(kind) => Error::new(
637 ErrorKind::CaptchaRequired,
638 format!(
639 "Grab asked for an extra check ({kind}) that needs a person. Log in at food.grab.com \
640 in a normal browser window instead, then run `grab auth import`"
641 ),
642 ),
643 Screen::Problem(p) if p.contains("block") || p.contains("many") || p.contains("later") => {
644 Error::new(
645 ErrorKind::RateLimited,
646 format!("Grab says: {p}. Wait before trying again."),
647 )
648 }
649 Screen::Problem(p) => Error::new(ErrorKind::LoginExpired, format!("Grab says: {p}")),
650 other => Error::new(
651 ErrorKind::Api,
652 format!(
653 "the login page is in an unexpected state ({})",
654 screen_name(other)
655 ),
656 ),
657 }
658}
659
660fn screen_name(s: &Screen) -> &'static str {
661 match s {
662 Screen::Loading => "loading",
663 Screen::Phone => "phone form",
664 Screen::Options(_) => "challenge options",
665 Screen::Otp { .. } => "code entry",
666 Screen::OtpRejected => "code rejected",
667 Screen::Approval => "in-app approval",
668 Screen::Done => "logged in",
669 Screen::StepUp(_) => "step-up",
670 Screen::Problem(_) => "error banner",
671 Screen::Unknown(_) => "unknown",
672 }
673}
674
675async fn dispose(cdp_base: &str, context: &str) {
677 if let Ok(mut b) = Browser::connect(cdp_base).await {
678 b.dispose_context(context).await.ok();
679 }
680}
681
682pub async fn start(opts: StartOpts<'_>, budget: &dyn SendBudget) -> Result<PendingLogin> {
687 let now = jwt::now();
688 let (cc, national) = split_phone(opts.phone)?;
689 let masked = mask_phone(&cc, &national);
690 budget.check(now).await?;
691
692 let mut browser = Browser::connect(opts.cdp_base).await?;
693 let ctx = browser.create_context().await?;
694 let result = drive_send(browser, &ctx, opts, &cc, &national, &masked, budget).await;
695 match result {
696 Ok(p) => Ok(p),
697 Err(e) => {
698 dispose(opts.cdp_base, &ctx).await;
699 Err(e)
700 }
701 }
702}
703
704async fn drive_send(
705 mut browser: Browser,
706 ctx: &str,
707 opts: StartOpts<'_>,
708 cc: &str,
709 national: &str,
710 masked: &str,
711 budget: &dyn SendBudget,
712) -> Result<PendingLogin> {
713 let target = browser.open_tab(ctx, "about:blank").await?;
714 let session = browser.attach(&target).await?;
715 browser.navigate(&session, LOGIN_START_URL).await?;
716 let mut tab = Tab { browser, session };
717
718 let s = tab
719 .wait_for(Duration::from_secs(45), |s| !matches!(s, Screen::Loading))
720 .await?;
721 if s != Screen::Phone {
722 return Err(page_error(&s));
723 }
724 tokio::time::sleep(Duration::from_secs(2)).await;
726
727 let shown = tab.probe_text().await?;
728 if !shown.contains(&format!("+{cc}")) {
729 return Err(Error::invalid(format!(
730 "the login page isn't set to +{cc}; only the page's default country is supported. Nothing was sent."
731 )));
732 }
733 if tab
734 .browser
735 .eval(&tab.session, REMEMBER_STATE)
736 .await?
737 .as_str()
738 == Some("unchecked")
739 {
740 tab.click(REMEMBER_LABEL, "remember-me checkbox").await?;
741 }
742 tab.click(PHONE_INPUT, "phone field").await?;
743 tab.browser.type_digits(&tab.session, national).await?;
744 let typed = tab
745 .browser
746 .eval(
747 &tab.session,
748 "(document.getElementById('Phone') || {}).value.replace(/\\D/g, '')",
749 )
750 .await?;
751 if typed.as_str() != Some(national) {
752 return Err(Error::new(
753 ErrorKind::Api,
754 "the phone field didn't take the number as typed; nothing was sent",
755 ));
756 }
757 tokio::time::sleep(Duration::from_millis(600)).await;
758 match tab.rect(CONTINUE_BTN).await? {
759 Some((_, _, false)) => {}
760 _ => {
761 return Err(Error::new(
762 ErrorKind::Api,
763 "the Continue button is missing or disabled; nothing was sent",
764 ));
765 }
766 }
767
768 budget.record(jwt::now()).await?;
771 tab.click(CONTINUE_BTN, "Continue button").await?;
772 let mut s = tab
773 .wait_for(Duration::from_secs(40), |s| {
774 !matches!(s, Screen::Phone | Screen::Loading)
775 })
776 .await?;
777 if let Screen::Options(offered) = &s {
778 let Some(kind) = pick_option(opts.channel, offered) else {
779 return Err(Error::invalid(format!(
780 "Grab didn't offer {} for this account (offered: {}). Nothing was sent; the attempt still counts toward the limit.",
781 opts.channel.as_str(),
782 offered.join(", ")
783 )));
784 };
785 let opt = format!(
786 "(() => {{ const i = document.querySelector('[role=button] img[alt=\"Proceed to {kind}\"]'); return i ? i.closest('[role=button]') : null; }})()"
787 );
788 tab.click(&opt, "challenge option").await?;
789 s = tab
790 .wait_for(Duration::from_secs(40), |s| {
791 !matches!(s, Screen::Options(_) | Screen::Loading)
792 })
793 .await?;
794 }
795 let (kind, channel_sent) = match &s {
796 Screen::Otp { channel } => (PendingKind::OtpSent, channel.clone()),
797 Screen::Approval => (PendingKind::ApprovalPending, Some("app".to_string())),
798 Screen::Phone => {
799 return Err(Error::new(
800 ErrorKind::Api,
801 "the login page stayed on the phone form after Continue (maybe a field error). Not retrying.",
802 ));
803 }
804 other => return Err(page_error(other)),
805 };
806 let now = jwt::now();
807 let p = PendingLogin {
808 kind,
809 cdp_base: opts.cdp_base.to_string(),
810 browser_context_id: ctx.to_string(),
811 target_id: target,
812 created_at: now,
813 expires_at: now + PENDING_TTL_SECS,
814 phone_masked: masked.to_string(),
815 channel_requested: opts.channel,
816 channel_sent,
817 copy_browser_address: opts.copy_browser_address,
818 };
819 Ok(p)
820}
821
822impl Tab {
823 async fn probe_text(&mut self) -> Result<String> {
824 let v = self.browser.eval(&self.session, PROBE_JS).await?;
825 Ok(v.get("text")
826 .and_then(Value::as_str)
827 .unwrap_or_default()
828 .to_string())
829 }
830}
831
832pub async fn check_no_pending(slot: &mut Option<PendingLogin>, now: i64) -> Result<()> {
835 if let Some(p) = slot.as_ref() {
836 if !p.is_expired(now) {
837 return Err(Error::invalid(format!(
838 "a login for {} is already pending ({}, {}s left): finish it with `grab auth login --otp <code>` / `--poll`, or run `grab auth login --cancel`",
839 p.phone_masked,
840 p.kind.as_str(),
841 p.expires_at - now
842 )));
843 }
844 dispose(&p.cdp_base, &p.browser_context_id).await;
845 *slot = None;
846 }
847 Ok(())
848}
849
850async fn reattach(slot: &mut Option<PendingLogin>) -> Result<(Tab, PendingLogin)> {
853 let p = slot.clone().ok_or_else(no_pending)?;
854 if p.is_expired(jwt::now()) {
855 dispose(&p.cdp_base, &p.browser_context_id).await;
856 *slot = None;
857 return Err(Error::new(
858 ErrorKind::LoginExpired,
859 "the pending login expired; start again with `grab auth login --phone <number>` (mind the send limits)",
860 ));
861 }
862 let mut browser = Browser::connect(&p.cdp_base).await?;
863 if !browser.context_exists(&p.browser_context_id).await?
864 || !browser
865 .tab_in_context(&p.browser_context_id, &p.target_id)
866 .await?
867 {
868 *slot = None;
869 return Err(Error::new(
870 ErrorKind::LoginExpired,
871 "the login tab is gone (browser restarted or tab closed); start again with `grab auth login --phone <number>`",
872 ));
873 }
874 let session = browser.attach(&p.target_id).await?;
875 Ok((Tab { browser, session }, p))
876}
877
878async fn collect_session(
881 tab: &mut Tab,
882 p: &PendingLogin,
883 slot: &mut Option<PendingLogin>,
884 prev: &Session,
885) -> Result<Session> {
886 let deadline = tokio::time::Instant::now() + Duration::from_secs(45);
887 loop {
888 let cookies = tab.browser.context_cookies(&p.browser_context_id).await?;
889 let mut s = prev.clone();
890 s.cookies = cookies;
891 if s.gfc_logged_in() {
892 s.carry_over_preferences(prev);
897 if p.copy_browser_address
898 && s.cookie(LOCATION_COOKIE).is_none()
899 && let Ok(def) = tab.browser.default_cookies().await
900 {
901 s.replace_named_cookies(LOCATION_COOKIE, &def);
902 }
903 if !tab.browser.user_agent.is_empty() {
904 s.user_agent = Some(tab.browser.user_agent.clone());
905 }
906 s.exported_at = Some(jwt::now() as f64);
907 tab.browser
908 .dispose_context(&p.browser_context_id)
909 .await
910 .ok();
911 *slot = None;
912 return Ok(s);
913 }
914 if tokio::time::Instant::now() >= deadline {
915 return Err(Error::new(
916 ErrorKind::Api,
917 "the login page finished but food.grab.com set no logged-in gfc_session; the tab is kept until the pending login expires",
918 ));
919 }
920 tokio::time::sleep(Duration::from_secs(1)).await;
921 }
922}
923
924pub async fn finish_with_otp(
929 slot: &mut Option<PendingLogin>,
930 otp: &str,
931 prev: &Session,
932) -> Result<Session> {
933 let otp = otp.trim();
934 if !(4..=8).contains(&otp.len()) || !otp.chars().all(|c| c.is_ascii_digit()) {
935 return Err(Error::invalid("the code should be 4-8 digits"));
936 }
937 let kind = slot.as_ref().ok_or_else(no_pending)?.kind;
938 if kind != PendingKind::OtpSent {
939 return Err(Error::invalid(
940 "this login is waiting for an in-app approval: run `grab auth login --poll`",
941 ));
942 }
943 let (mut tab, p) = reattach(slot).await?;
944 match tab.probe().await? {
945 Screen::Otp { .. } | Screen::OtpRejected => {}
946 Screen::Done => return collect_session(&mut tab, &p, slot, prev).await,
947 other => return Err(finish_error(&p, slot, &other).await),
948 }
949 tab.click(OTP_INPUT, "code field").await?;
950 tab.browser.type_digits(&tab.session, otp).await?;
951 let s = tab
953 .wait_for(Duration::from_secs(45), |s| {
954 !matches!(s, Screen::Otp { .. } | Screen::Loading)
955 })
956 .await?;
957 match s {
958 Screen::Done => collect_session(&mut tab, &p, slot, prev).await,
959 Screen::OtpRejected => Err(Error::new(
960 ErrorKind::OtpInvalid,
961 "Grab didn't accept the code; check it and run `grab auth login --otp <code>` again (the page allows only a few tries)",
962 )),
963 Screen::Otp { .. } | Screen::Loading => {
964 collect_session(&mut tab, &p, slot, prev).await
966 }
967 other => Err(finish_error(&p, slot, &other).await),
968 }
969}
970
971#[derive(Debug)]
973pub enum WaitOutcome {
974 LoggedIn(Session),
975 StillWaiting,
977}
978
979pub async fn wait_for_approval(
982 slot: &mut Option<PendingLogin>,
983 timeout: Duration,
984 prev: &Session,
985) -> Result<WaitOutcome> {
986 let kind = slot.as_ref().ok_or_else(no_pending)?.kind;
987 if kind != PendingKind::ApprovalPending {
988 return Err(Error::invalid(
989 "this login is waiting for a code: run `grab auth login --otp <code>`",
990 ));
991 }
992 let (mut tab, p) = reattach(slot).await?;
993 let left = u64::try_from(p.expires_at - jwt::now()).unwrap_or(0);
994 let timeout = timeout.min(Duration::from_secs(left));
995 let s = tab
996 .wait_for(timeout, |s| {
997 !matches!(s, Screen::Approval | Screen::Loading)
998 })
999 .await?;
1000 match s {
1001 Screen::Done => Ok(WaitOutcome::LoggedIn(
1002 collect_session(&mut tab, &p, slot, prev).await?,
1003 )),
1004 Screen::Approval | Screen::Loading => Ok(WaitOutcome::StillWaiting),
1005 other => Err(finish_error(&p, slot, &other).await),
1006 }
1007}
1008
1009async fn finish_error(p: &PendingLogin, slot: &mut Option<PendingLogin>, s: &Screen) -> Error {
1011 dispose(&p.cdp_base, &p.browser_context_id).await;
1012 *slot = None;
1013 page_error(s)
1014}
1015
1016fn no_pending() -> Error {
1017 Error::new(
1018 ErrorKind::LoginExpired,
1019 "no login in progress: start one with `grab auth login --phone <number>`",
1020 )
1021}
1022
1023pub async fn cancel(slot: &mut Option<PendingLogin>) -> bool {
1027 let Some(p) = slot.take() else {
1028 return false;
1029 };
1030 dispose(&p.cdp_base, &p.browser_context_id).await;
1031 true
1032}
1033
1034pub async fn shared_browser_tabs(cdp_base: &str) -> Vec<String> {
1039 let first = cdp::pages(cdp_base)
1040 .await
1041 .map(|p| order_tracking_tabs(&p))
1042 .unwrap_or_default();
1043 if first.is_empty() {
1044 return first;
1045 }
1046 tokio::time::sleep(Duration::from_millis(1500)).await;
1047 let second = cdp::pages(cdp_base)
1048 .await
1049 .map(|p| order_tracking_tabs(&p))
1050 .unwrap_or_default();
1051 still_open(&first, &second)
1052}
1053
1054pub fn still_open(first: &[String], second: &[String]) -> Vec<String> {
1056 first
1057 .iter()
1058 .filter(|t| second.contains(t))
1059 .cloned()
1060 .collect()
1061}
1062
1063#[derive(Debug, Clone)]
1068pub enum LoginState {
1069 AwaitingCode {
1072 phone_masked: String,
1073 channel: Option<String>,
1075 expires_at: i64,
1076 error: Option<String>,
1077 },
1078 AwaitingApproval {
1080 phone_masked: String,
1081 expires_at: i64,
1082 },
1083 Done(Session),
1086 Failed(String),
1088}
1089
1090impl LoginState {
1091 pub fn is_terminal(&self) -> bool {
1092 matches!(self, Self::Done(_) | Self::Failed(_))
1093 }
1094}
1095
1096#[derive(Clone)]
1098pub struct LoginRequest<'a> {
1099 pub cdp_url: &'a str,
1101 pub phone: &'a str,
1104 pub channel: Channel,
1105 pub previous: Option<Session>,
1108 pub copy_browser_address: bool,
1111}
1112
1113impl std::fmt::Debug for LoginRequest<'_> {
1114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1115 f.debug_struct("LoginRequest")
1116 .field("cdp_url", &self.cdp_url)
1117 .field("phone", &"<redacted>")
1118 .field("channel", &self.channel)
1119 .finish_non_exhaustive()
1120 }
1121}
1122
1123#[derive(Debug)]
1131pub struct LoginDriver {
1132 pending: Option<PendingLogin>,
1133 previous: Session,
1134 state: LoginState,
1135}
1136
1137fn failed(e: &Error) -> LoginState {
1138 LoginState::Failed(format!("{}: {}", e.kind.as_str(), e.message))
1139}
1140
1141impl LoginDriver {
1142 pub async fn start(req: LoginRequest<'_>, budget: &dyn SendBudget) -> Self {
1145 let previous = req.previous.clone().unwrap_or_default();
1146 let opts = StartOpts {
1147 cdp_base: req.cdp_url,
1148 phone: req.phone,
1149 channel: req.channel,
1150 copy_browser_address: req.copy_browser_address,
1151 };
1152 match start(opts, budget).await {
1153 Ok(p) => {
1154 let state = Self::waiting(&p, None);
1155 Self {
1156 pending: Some(p),
1157 previous,
1158 state,
1159 }
1160 }
1161 Err(e) => Self {
1162 pending: None,
1163 previous,
1164 state: failed(&e),
1165 },
1166 }
1167 }
1168
1169 fn waiting(p: &PendingLogin, error: Option<String>) -> LoginState {
1170 match p.kind {
1171 PendingKind::OtpSent => LoginState::AwaitingCode {
1172 phone_masked: p.phone_masked.clone(),
1173 channel: p.channel_sent.clone(),
1174 expires_at: p.expires_at,
1175 error,
1176 },
1177 PendingKind::ApprovalPending => LoginState::AwaitingApproval {
1178 phone_masked: p.phone_masked.clone(),
1179 expires_at: p.expires_at,
1180 },
1181 }
1182 }
1183
1184 pub fn state(&self) -> &LoginState {
1185 &self.state
1186 }
1187
1188 pub fn pending(&self) -> Option<&PendingLogin> {
1190 self.pending.as_ref()
1191 }
1192
1193 fn after_error(&mut self, e: &Error) {
1196 self.state = match &self.pending {
1197 Some(p) => Self::waiting(p, Some(e.message.clone())),
1198 None => failed(e),
1199 };
1200 }
1201
1202 pub async fn submit_code(&mut self, code: &str) -> &LoginState {
1204 if !matches!(self.state, LoginState::AwaitingCode { .. }) {
1205 return &self.state;
1206 }
1207 match finish_with_otp(&mut self.pending, code, &self.previous).await {
1208 Ok(s) => self.state = LoginState::Done(s),
1209 Err(e) => self.after_error(&e),
1210 }
1211 &self.state
1212 }
1213
1214 pub async fn poll(&mut self, wait: Duration) -> &LoginState {
1217 if !matches!(self.state, LoginState::AwaitingApproval { .. }) {
1218 return &self.state;
1219 }
1220 match wait_for_approval(&mut self.pending, wait, &self.previous).await {
1221 Ok(WaitOutcome::LoggedIn(s)) => self.state = LoginState::Done(s),
1222 Ok(WaitOutcome::StillWaiting) => {}
1223 Err(e) => self.after_error(&e),
1224 }
1225 &self.state
1226 }
1227
1228 pub async fn cancel(&mut self) -> &LoginState {
1231 if !self.state.is_terminal() {
1232 cancel(&mut self.pending).await;
1233 self.state = LoginState::Failed("cancelled".into());
1234 }
1235 &self.state
1236 }
1237
1238 pub fn into_session(self) -> Option<Session> {
1240 match self.state {
1241 LoginState::Done(s) => Some(s),
1242 _ => None,
1243 }
1244 }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249 use super::*;
1250
1251 #[test]
1252 fn splits_and_masks_phone() -> Result<()> {
1253 let (cc, n) = split_phone("+63 900 000 0012")?;
1254 assert_eq!((cc.as_str(), n.as_str()), ("63", "9000000012"));
1255 assert_eq!(mask_phone(&cc, &n), "+63••••••••12");
1256 assert!(!mask_phone(&cc, &n).contains("900"));
1257 let (cc, n) = split_phone("+65-8000-0000")?;
1258 assert_eq!((cc.as_str(), n.as_str()), ("65", "80000000"));
1259 assert!(split_phone("09000000000").is_err());
1260 assert!(split_phone("+1 555 000 0000").is_err());
1261 assert!(split_phone("+63 12").is_err());
1262 assert_eq!(mask_phone("63", "1"), "+631");
1263 Ok(())
1264 }
1265
1266 #[test]
1267 fn channels() -> Result<()> {
1268 assert_eq!(Channel::parse("APP")?, Channel::App);
1269 assert_eq!(Channel::parse("whatsapp")?, Channel::Whatsapp);
1270 assert!(Channel::parse("email").is_err());
1271 let offered = vec![
1272 "PUSH_V2".to_string(),
1273 "CHANNEL_OPTIMIZED_PHONE_OTP".to_string(),
1274 ];
1275 assert_eq!(pick_option(Channel::App, &offered), Some("PUSH_V2"));
1276 assert_eq!(
1277 pick_option(Channel::Sms, &offered),
1278 Some("CHANNEL_OPTIMIZED_PHONE_OTP")
1279 );
1280 assert_eq!(pick_option(Channel::Sms, &["PUSH".to_string()]), None);
1281 Ok(())
1282 }
1283
1284 #[test]
1285 fn cooldown_and_hourly_cap() {
1286 let mut log = SendLog::default();
1287 let t0 = 1_000_000;
1288 assert!(log.check(t0).is_ok());
1289 log.record(t0);
1290 assert_eq!(log.wait_secs(t0 + 10), 50);
1292 let e = log.check(t0 + 10).err();
1293 assert_eq!(e.as_ref().map(|e| e.kind), Some(ErrorKind::RateLimited));
1294 assert!(log.check(t0 + 60).is_ok());
1295 log.record(t0 + 60);
1296 log.record(t0 + 200);
1297 assert_eq!(log.sends_last_hour(t0 + 300), 3);
1299 assert_eq!(log.wait_secs(t0 + 300), 3600 - 300);
1300 assert!(log.check(t0 + 3599).is_err());
1301 assert!(log.check(t0 + 3600).is_ok());
1302 log.record(t0 + 3700);
1304 assert_eq!(log.sends.len(), 2);
1305 }
1306
1307 fn pending() -> PendingLogin {
1308 PendingLogin {
1309 kind: PendingKind::OtpSent,
1310 cdp_base: "http://127.0.0.1:9222".into(),
1311 browser_context_id: "CTX".into(),
1312 target_id: "TAB".into(),
1313 created_at: 100,
1314 expires_at: 100 + PENDING_TTL_SECS,
1315 phone_masked: mask_phone("63", "9000000012"),
1316 channel_requested: Channel::Sms,
1317 channel_sent: Some("SMS".into()),
1318 copy_browser_address: false,
1319 }
1320 }
1321
1322 #[test]
1323 fn pending_serializes_without_phone() -> Result<()> {
1324 let p = pending();
1325 let raw = serde_json::to_string(&p)?;
1326 assert!(!raw.contains("9000000012"));
1327 let back: PendingLogin = serde_json::from_str(&raw)?;
1328 assert_eq!(back.kind, PendingKind::OtpSent);
1329 assert!(!back.copy_browser_address);
1330 assert!(!back.is_expired(200));
1331 assert!(back.is_expired(100 + PENDING_TTL_SECS));
1332 assert_eq!(back.summary(160)["expires_in_secs"], json!(540));
1333 let j = pending_json(&back, 160);
1334 assert_eq!(j["kind"], json!("otp_sent"));
1335 assert_eq!(j["expires_at"], json!(100 + PENDING_TTL_SECS));
1336 assert_eq!(j["next"], json!("grab auth login --otp <code>"));
1337 let approval = PendingLogin {
1338 kind: PendingKind::ApprovalPending,
1339 ..back
1340 };
1341 assert_eq!(
1342 pending_json(&approval, 160)["next"],
1343 json!("grab auth login --poll")
1344 );
1345 let mut v = serde_json::to_value(&p)?;
1347 v.as_object_mut().map(|o| o.remove("copy_browser_address"));
1348 assert!(serde_json::from_value::<PendingLogin>(v)?.copy_browser_address);
1349 Ok(())
1350 }
1351
1352 #[tokio::test]
1353 async fn memory_budget_enforces_limits() -> Result<()> {
1354 let b = MemorySendBudget::default();
1355 b.check(1000).await?;
1356 b.record(1000).await?;
1357 let e = b.check(1010).await.err();
1358 assert_eq!(e.map(|e| e.kind), Some(ErrorKind::RateLimited));
1359 assert_eq!(b.snapshot().sends, vec![1000]);
1360 Ok(())
1361 }
1362
1363 #[tokio::test]
1364 async fn driver_never_sends_when_budget_is_spent() -> Result<()> {
1365 let b = MemorySendBudget::default();
1366 b.record(jwt::now()).await?;
1367 let d = LoginDriver::start(
1370 LoginRequest {
1371 cdp_url: "http://127.0.0.1:1",
1372 phone: "+63 900 000 0012",
1373 channel: Channel::App,
1374 previous: None,
1375 copy_browser_address: false,
1376 },
1377 &b,
1378 )
1379 .await;
1380 match d.state() {
1381 LoginState::Failed(r) => {
1382 assert!(r.starts_with("rate_limited"), "{r}");
1383 assert!(!r.contains("0012"));
1384 }
1385 other => panic!("unexpected {other:?}"),
1386 }
1387 assert_eq!(b.snapshot().sends.len(), 1);
1388 Ok(())
1389 }
1390
1391 #[tokio::test]
1392 async fn driver_rejects_bad_phone_without_sending() {
1393 let b = MemorySendBudget::default();
1394 let mut d = LoginDriver::start(
1395 LoginRequest {
1396 cdp_url: "http://127.0.0.1:1",
1397 phone: "0900",
1398 channel: Channel::Sms,
1399 previous: None,
1400 copy_browser_address: false,
1401 },
1402 &b,
1403 )
1404 .await;
1405 assert!(matches!(d.state(), LoginState::Failed(_)));
1406 assert!(b.snapshot().sends.is_empty());
1407 assert!(matches!(
1409 d.submit_code("123456").await,
1410 LoginState::Failed(_)
1411 ));
1412 assert!(matches!(d.cancel().await, LoginState::Failed(_)));
1413 assert!(d.into_session().is_none());
1414 }
1415
1416 #[test]
1417 fn dry_run_sends_nothing_and_reports_limits() {
1418 let mut log = SendLog::default();
1419 log.record(1000);
1420 let v = dry_run_plan("+63••••••••12", Channel::App, "http://x:9222", &log, 1030);
1421 assert_eq!(v["kind"], json!("dry_run"));
1422 assert_eq!(v["dry_run"], json!(true));
1423 assert_eq!(v["sent"], json!(false));
1424 assert_eq!(v["phone"], json!("+63••••••••12"));
1425 assert_eq!(v["sends_last_hour"], json!(1));
1426 assert_eq!(v["next_send_allowed_in_secs"], json!(30));
1427 assert!(
1428 v["would_send"]
1429 .as_str()
1430 .unwrap_or_default()
1431 .contains("approval")
1432 );
1433 }
1434
1435 fn probe(host: &str, path: &str, text: &str) -> Value {
1436 json!({"host": host, "path": path, "text": text, "has_phone": false, "options": [], "captcha": false})
1437 }
1438
1439 #[test]
1440 fn classifies_screens() {
1441 let mut phone = probe(
1442 "weblogin.grab.com",
1443 "/passenger/login",
1444 "Sign in to Grab\n+63",
1445 );
1446 phone["has_phone"] = json!(true);
1447 assert_eq!(classify_screen(&phone), Screen::Phone);
1448 assert_eq!(
1449 classify_screen(&probe("weblogin.grab.com", "/passenger/login", "")),
1450 Screen::Loading
1451 );
1452 let mut opts = probe(
1453 "weblogin.grab.com",
1454 "/passenger/challenge-options",
1455 "Choose a preferred way to sign in",
1456 );
1457 opts["options"] = json!(["PUSH_V2", "CHANNEL_OPTIMIZED_PHONE_OTP"]);
1458 assert_eq!(
1459 classify_screen(&opts),
1460 Screen::Options(vec!["PUSH_V2".into(), "CHANNEL_OPTIMIZED_PHONE_OTP".into()])
1461 );
1462 assert_eq!(
1463 classify_screen(&probe(
1464 "weblogin.grab.com",
1465 "/passenger/optimised-sms-otp",
1466 "Check your WhatsApp\nWe've sent a verification code"
1467 )),
1468 Screen::Otp {
1469 channel: Some("WhatsApp".into())
1470 }
1471 );
1472 assert_eq!(
1473 classify_screen(&probe(
1474 "weblogin.grab.com",
1475 "/passenger/push-v2",
1476 "Approve in mobile app"
1477 )),
1478 Screen::Approval
1479 );
1480 assert_eq!(
1481 classify_screen(&probe(
1482 "weblogin.grab.com",
1483 "/passenger/optimised-sms-otp",
1484 "Incorrect code. You can try 2 more times."
1485 )),
1486 Screen::OtpRejected
1487 );
1488 assert!(matches!(
1489 classify_screen(&probe(
1490 "weblogin.grab.com",
1491 "/passenger/x",
1492 "Account temporarily blocked"
1493 )),
1494 Screen::Problem(_)
1495 ));
1496 assert_eq!(
1497 classify_screen(&probe("weblogin.grab.com", "/passenger/web-selfie", "")),
1498 Screen::StepUp("web-selfie".into())
1499 );
1500 let mut cap = probe("weblogin.grab.com", "/passenger/login", "");
1501 cap["captcha"] = json!(true);
1502 assert_eq!(classify_screen(&cap), Screen::StepUp("recaptcha".into()));
1503 assert_eq!(
1504 classify_screen(&probe("food.grab.com", "/ph/en/", "")),
1505 Screen::Done
1506 );
1507 assert_eq!(
1508 classify_screen(&probe("food.grab.com", "/auth/callback", "")),
1509 Screen::Loading
1510 );
1511 assert_eq!(
1513 classify_screen(&probe(
1514 "weblogin.grab.com",
1515 "/passenger/new",
1516 "Check your SMS"
1517 )),
1518 Screen::Otp {
1519 channel: Some("SMS".into())
1520 }
1521 );
1522 }
1523
1524 #[test]
1525 fn finds_tracking_tabs() {
1526 let pages = vec![
1527 PageInfo {
1528 id: "1".into(),
1529 url: "https://food.grab.com/ph/en/order/001-ABC".into(),
1530 },
1531 PageInfo {
1532 id: "2".into(),
1533 url: "https://food.grab.com/ph/en/restaurants".into(),
1534 },
1535 PageInfo {
1536 id: "3".into(),
1537 url: "https://example.com/order/1".into(),
1538 },
1539 ];
1540 let tabs = order_tracking_tabs(&pages);
1541 assert_eq!(tabs, vec!["food.grab.com/ph/en/order/001-ABC".to_string()]);
1542 assert!(tracking_tab_warning(&tabs).is_some_and(|w| w.contains("logout")));
1543 assert!(tracking_tab_warning(&[]).is_none());
1544 }
1545
1546 #[test]
1547 fn stale_tabs_are_dropped() {
1548 let a = "food.grab.com/ph/en/order/A".to_string();
1549 let b = "food.grab.com/ph/en/order/B".to_string();
1550 assert_eq!(
1551 still_open(&[a.clone(), b.clone()], std::slice::from_ref(&b)),
1552 vec![b.clone()]
1553 );
1554 assert!(still_open(&[a], &[]).is_empty());
1555 }
1556}