From aeeacecc1519ae82bf0954789da2f9c4cf129a5d Mon Sep 17 00:00:00 2001 From: Miliviu Date: Fri, 16 Jan 2026 00:28:53 +0100 Subject: [PATCH 1/2] feat: add Glovo provider support Adds CLI commands for interacting with the Glovo API: - config show/set: manage base URL, city/country codes, coordinates - session/logout: authenticate via access token - history: list past orders - order: view single order details - orders: show active orders with --watch mode - cart: display shopping baskets - me: show user profile --- internal/cli/glovo_cmd.go | 483 +++++++++++++++++++++++++++++++++ internal/cli/glovo_cmd_test.go | 162 +++++++++++ internal/cli/run.go | 1 + internal/cli/state.go | 2 + internal/config/config.go | 21 ++ internal/glovo/client.go | 263 ++++++++++++++++++ internal/glovo/errors.go | 24 ++ internal/glovo/models.go | 152 +++++++++++ 8 files changed, 1108 insertions(+) create mode 100644 internal/cli/glovo_cmd.go create mode 100644 internal/cli/glovo_cmd_test.go create mode 100644 internal/glovo/client.go create mode 100644 internal/glovo/errors.go create mode 100644 internal/glovo/models.go diff --git a/internal/cli/glovo_cmd.go b/internal/cli/glovo_cmd.go new file mode 100644 index 0000000..e4cae33 --- /dev/null +++ b/internal/cli/glovo_cmd.go @@ -0,0 +1,483 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/steipete/ordercli/internal/glovo" +) + +func newGlovoCmd(st *state) *cobra.Command { + cmd := &cobra.Command{ + Use: "glovo", + Short: "Glovo", + } + cmd.AddCommand(newGlovoConfigCmd(st)) + cmd.AddCommand(newGlovoSessionCmd(st)) + cmd.AddCommand(newGlovoLogoutCmd(st)) + cmd.AddCommand(newGlovoHistoryCmd(st)) + cmd.AddCommand(newGlovoOrderCmd(st)) + cmd.AddCommand(newGlovoOrdersCmd(st)) + cmd.AddCommand(newGlovoCartCmd(st)) + cmd.AddCommand(newGlovoMeCmd(st)) + return cmd +} + +func newGlovoClient(st *state) (*glovo.Client, error) { + cfg := st.glovo() + return glovo.New(glovo.Options{ + BaseURL: cfg.BaseURL, + AccessToken: cfg.AccessToken, + DeviceURN: cfg.DeviceURN, + CityCode: cfg.CityCode, + CountryCode: cfg.CountryCode, + Language: cfg.Language, + Latitude: cfg.Latitude, + Longitude: cfg.Longitude, + }) +} + +// Config commands + +func newGlovoConfigCmd(st *state) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Show/edit Glovo config", + } + cmd.AddCommand(newGlovoConfigShowCmd(st)) + cmd.AddCommand(newGlovoConfigSetCmd(st)) + return cmd +} + +func newGlovoConfigShowCmd(st *state) *cobra.Command { + return &cobra.Command{ + Use: "show", + Short: "Print current Glovo config", + Run: func(cmd *cobra.Command, args []string) { + cfg := st.glovo() + fmt.Fprintf(cmd.OutOrStdout(), "base_url=%s\n", cfg.BaseURL) + fmt.Fprintf(cmd.OutOrStdout(), "city_code=%s\n", cfg.CityCode) + fmt.Fprintf(cmd.OutOrStdout(), "country_code=%s\n", cfg.CountryCode) + fmt.Fprintf(cmd.OutOrStdout(), "language=%s\n", cfg.Language) + fmt.Fprintf(cmd.OutOrStdout(), "latitude=%v\n", cfg.Latitude) + fmt.Fprintf(cmd.OutOrStdout(), "longitude=%v\n", cfg.Longitude) + if cfg.AccessToken != "" { + fmt.Fprintf(cmd.OutOrStdout(), "access_token=%s...\n", cfg.AccessToken[:min(20, len(cfg.AccessToken))]) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "access_token=(not set)\n") + } + }, + } +} + +func newGlovoConfigSetCmd(st *state) *cobra.Command { + var cityCode, countryCode, language, baseURL string + var lat, lon float64 + + cmd := &cobra.Command{ + Use: "set", + Short: "Update Glovo config", + RunE: func(cmd *cobra.Command, args []string) error { + cfg := st.glovo() + changed := false + + if strings.TrimSpace(cityCode) != "" { + cfg.CityCode = strings.ToUpper(strings.TrimSpace(cityCode)) + changed = true + } + if strings.TrimSpace(countryCode) != "" { + cfg.CountryCode = strings.ToUpper(strings.TrimSpace(countryCode)) + changed = true + } + if strings.TrimSpace(language) != "" { + cfg.Language = strings.ToLower(strings.TrimSpace(language)) + changed = true + } + if strings.TrimSpace(baseURL) != "" { + cfg.BaseURL = strings.TrimSpace(baseURL) + changed = true + } + if cmd.Flags().Changed("lat") { + cfg.Latitude = lat + changed = true + } + if cmd.Flags().Changed("lon") { + cfg.Longitude = lon + changed = true + } + + if !changed { + return fmt.Errorf("nothing to set (use --city-code, --country-code, --language, --lat, --lon, or --base-url)") + } + st.markDirty() + fmt.Fprintln(cmd.OutOrStdout(), "config updated") + return nil + }, + } + + cmd.Flags().StringVar(&cityCode, "city-code", "", "city code (e.g. MAD)") + cmd.Flags().StringVar(&countryCode, "country-code", "", "country code (e.g. ES)") + cmd.Flags().StringVar(&language, "language", "", "language code (e.g. en)") + cmd.Flags().StringVar(&baseURL, "base-url", "", "API base URL") + cmd.Flags().Float64Var(&lat, "lat", 0, "delivery latitude") + cmd.Flags().Float64Var(&lon, "lon", 0, "delivery longitude") + return cmd +} + +// Session command + +func newGlovoSessionCmd(st *state) *cobra.Command { + return &cobra.Command{ + Use: "session ", + Short: "Set access token (from browser localStorage glovo_auth_info)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + token := strings.TrimSpace(args[0]) + if token == "" { + return fmt.Errorf("access token cannot be empty") + } + + cfg := st.glovo() + cfg.AccessToken = token + st.markDirty() + + fmt.Fprintln(cmd.OutOrStdout(), "access token saved") + return nil + }, + } +} + +// Logout command + +func newGlovoLogoutCmd(st *state) *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Clear stored access token", + Run: func(cmd *cobra.Command, args []string) { + cfg := st.glovo() + cfg.AccessToken = "" + cfg.DeviceURN = "" + st.markDirty() + fmt.Fprintln(cmd.OutOrStdout(), "logged out") + }, + } +} + +// History command + +func newGlovoHistoryCmd(st *state) *cobra.Command { + var offset, limit int + var asJSON bool + + cmd := &cobra.Command{ + Use: "history", + Short: "List past orders", + RunE: func(cmd *cobra.Command, args []string) error { + cl, err := newGlovoClient(st) + if err != nil { + return err + } + + resp, err := cl.OrderHistory(cmd.Context(), offset, limit) + if err != nil { + return err + } + + if asJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(resp) + } + + out := cmd.OutOrStdout() + if len(resp.Orders) == 0 { + fmt.Fprintln(out, "no orders") + return nil + } + + for _, o := range resp.Orders { + title := o.Content.Title + price := "" + if o.Footer.Left != nil { + price = o.Footer.Left.DataString() + } + + items := "" + if len(o.Content.Body) > 0 { + // Get first few items + itemText := o.Content.Body[0].Data + lines := strings.Split(itemText, "\n") + if len(lines) > 3 { + items = strings.Join(lines[:3], ", ") + "..." + } else { + items = strings.Join(lines, ", ") + } + } + + fmt.Fprintf(out, "[%d] %s - %s\n", o.OrderID, title, price) + if items != "" { + fmt.Fprintf(out, " %s\n", items) + } + fmt.Fprintln(out) + } + + return nil + }, + } + + cmd.Flags().IntVar(&offset, "offset", 0, "paging offset") + cmd.Flags().IntVar(&limit, "limit", 12, "paging limit") + cmd.Flags().BoolVar(&asJSON, "json", false, "print raw JSON") + return cmd +} + +// Order command (single order details) + +func newGlovoOrderCmd(st *state) *cobra.Command { + var asJSON bool + + cmd := &cobra.Command{ + Use: "order ", + Short: "Show details for a single order", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var orderID int + if _, err := fmt.Sscanf(args[0], "%d", &orderID); err != nil { + return fmt.Errorf("invalid order ID: %s", args[0]) + } + + cl, err := newGlovoClient(st) + if err != nil { + return err + } + + order, err := cl.GetOrder(cmd.Context(), orderID) + if err != nil { + return err + } + + if asJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(order) + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Order ID: %d\n", order.OrderID) + fmt.Fprintf(out, "Store: %s\n", order.Content.Title) + fmt.Fprintf(out, "Status: %s\n", order.LayoutType) + if order.Footer.Left != nil { + fmt.Fprintf(out, "Total: %s\n", order.Footer.Left.DataString()) + } + if order.CourierName != nil { + fmt.Fprintf(out, "Courier: %s\n", *order.CourierName) + } + + if len(order.Content.Body) > 0 { + fmt.Fprintln(out, "\nItems:") + for _, b := range order.Content.Body { + lines := strings.Split(b.Data, "\n") + for _, line := range lines { + if line = strings.TrimSpace(line); line != "" { + fmt.Fprintf(out, " - %s\n", line) + } + } + } + } + + return nil + }, + } + + cmd.Flags().BoolVar(&asJSON, "json", false, "print raw JSON") + return cmd +} + +// Orders command (active orders tracking) + +func newGlovoOrdersCmd(st *state) *cobra.Command { + var asJSON bool + var watch bool + var interval int + + cmd := &cobra.Command{ + Use: "orders", + Short: "Show active orders (being delivered)", + RunE: func(cmd *cobra.Command, args []string) error { + cl, err := newGlovoClient(st) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + + printOrders := func() error { + orders, err := cl.ActiveOrders(cmd.Context()) + if err != nil { + return err + } + + if asJSON { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(orders) + } + + if len(orders) == 0 { + fmt.Fprintln(out, "no active orders") + return nil + } + + for _, o := range orders { + title := o.Content.Title + status := o.LayoutType + + fmt.Fprintf(out, "[%d] %s\n", o.OrderID, title) + fmt.Fprintf(out, " Status: %s\n", status) + if o.CourierName != nil { + fmt.Fprintf(out, " Courier: %s\n", *o.CourierName) + } + fmt.Fprintln(out) + } + return nil + } + + if watch { + for { + // Clear screen for fresh output + fmt.Fprint(out, "\033[2J\033[H") + fmt.Fprintf(out, "Active Orders (refreshing every %ds, Ctrl+C to stop)\n\n", interval) + if err := printOrders(); err != nil { + fmt.Fprintf(out, "Error: %v\n", err) + } + select { + case <-cmd.Context().Done(): + return nil + case <-time.After(time.Duration(interval) * time.Second): + // Continue to next iteration + } + } + } + + return printOrders() + }, + } + + cmd.Flags().BoolVar(&asJSON, "json", false, "print raw JSON") + cmd.Flags().BoolVar(&watch, "watch", false, "continuously poll for updates") + cmd.Flags().IntVar(&interval, "interval", 30, "polling interval in seconds (with --watch)") + return cmd +} + +// Cart command + +func newGlovoCartCmd(st *state) *cobra.Command { + var asJSON bool + + cmd := &cobra.Command{ + Use: "cart", + Short: "Show shopping cart (saved baskets)", + RunE: func(cmd *cobra.Command, args []string) error { + cl, err := newGlovoClient(st) + if err != nil { + return err + } + + // First get user ID + user, err := cl.Me(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + baskets, err := cl.Baskets(cmd.Context(), user.ID) + if err != nil { + return err + } + + if asJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(baskets) + } + + out := cmd.OutOrStdout() + if len(baskets) == 0 { + fmt.Fprintln(out, "cart is empty") + return nil + } + + for _, b := range baskets { + fmt.Fprintf(out, "Store: %s (ID: %d)\n", b.StoreName, b.StoreID) + fmt.Fprintf(out, " Items:\n") + for _, p := range b.Products { + fmt.Fprintf(out, " %dx %s - %.2f %s\n", p.Quantity, p.Name, p.TotalPrice, b.Currency) + } + fmt.Fprintf(out, " Subtotal: %.2f %s\n", b.SubTotal, b.Currency) + if b.DeliveryFee > 0 { + fmt.Fprintf(out, " Delivery: %.2f %s\n", b.DeliveryFee, b.Currency) + } + if b.ServiceFee > 0 { + fmt.Fprintf(out, " Service: %.2f %s\n", b.ServiceFee, b.Currency) + } + fmt.Fprintf(out, " Total: %.2f %s\n", b.Total, b.Currency) + if !b.IsMinOrderMet { + fmt.Fprintf(out, " ! Min order: %.2f %s\n", b.MinOrderValue, b.Currency) + } + fmt.Fprintln(out) + } + + return nil + }, + } + + cmd.Flags().BoolVar(&asJSON, "json", false, "print raw JSON") + return cmd +} + +// Me command + +func newGlovoMeCmd(st *state) *cobra.Command { + var asJSON bool + + cmd := &cobra.Command{ + Use: "me", + Short: "Show current user profile", + RunE: func(cmd *cobra.Command, args []string) error { + cl, err := newGlovoClient(st) + if err != nil { + return err + } + + user, err := cl.Me(cmd.Context()) + if err != nil { + return err + } + + if asJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(user) + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "ID: %d\n", user.ID) + fmt.Fprintf(out, "Name: %s\n", user.Name) + fmt.Fprintf(out, "Email: %s\n", user.Email) + if user.PhoneNumber != nil { + fmt.Fprintf(out, "Phone: %s\n", user.PhoneNumber.Number) + } + fmt.Fprintf(out, "City: %s\n", user.PreferredCityCode) + fmt.Fprintf(out, "Language: %s\n", user.PreferredLanguage) + fmt.Fprintf(out, "Orders: %d\n", user.DeliveredOrdersCount) + + return nil + }, + } + + cmd.Flags().BoolVar(&asJSON, "json", false, "print raw JSON") + return cmd +} diff --git a/internal/cli/glovo_cmd_test.go b/internal/cli/glovo_cmd_test.go new file mode 100644 index 0000000..886f0bc --- /dev/null +++ b/internal/cli/glovo_cmd_test.go @@ -0,0 +1,162 @@ +package cli + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func TestGlovoCLI_ConfigSetAndShow(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.json") + + // Test config set + out, _, err := runCLI(cfgPath, []string{"glovo", "config", "set", + "--city-code", "MAD", + "--country-code", "ES", + "--language", "en", + }, "") + if err != nil { + t.Fatalf("config set: %v out=%s", err, out) + } + + // Test config show + out, _, err = runCLI(cfgPath, []string{"glovo", "config", "show"}, "") + if err != nil { + t.Fatalf("config show: %v", err) + } + if !strings.Contains(out, "city_code=MAD") { + t.Fatalf("missing city_code in output: %s", out) + } + if !strings.Contains(out, "country_code=ES") { + t.Fatalf("missing country_code in output: %s", out) + } + if !strings.Contains(out, "language=en") { + t.Fatalf("missing language in output: %s", out) + } +} + +func TestGlovoCLI_SessionCommand(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.json") + + // Set access token + out, _, err := runCLI(cfgPath, []string{"glovo", "session", "test-token-12345"}, "") + if err != nil { + t.Fatalf("session: %v out=%s", err, out) + } + if !strings.Contains(out, "access token saved") { + t.Fatalf("unexpected output: %s", out) + } + + // Verify token is visible in config show (truncated with ...) + out, _, err = runCLI(cfgPath, []string{"glovo", "config", "show"}, "") + if err != nil { + t.Fatalf("config show: %v", err) + } + if !strings.Contains(out, "access_token=test-token-12345...") { + t.Fatalf("token not visible in config: %s", out) + } +} + +func TestGlovoCLI_History(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.json") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check path + if !strings.HasPrefix(r.URL.Path, "/v3/customer/orders-list") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + // Return mock order response + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "pagination": {"currentLimit": 12, "next": null}, + "orders": [{ + "orderId": 123, + "orderUrn": "glv:order:test", + "content": {"title": "Test Restaurant", "body": [{"type": "TEXT", "data": "1 x Item"}]}, + "footer": {"left": {"type": "TEXT", "data": "10,00 EUR"}, "right": null}, + "style": "DEFAULT", + "layoutType": "INACTIVE_ORDER", + "image": {"lightImageId": "", "darkImageId": ""} + }] + }`)) + })) + defer srv.Close() + + // Set config with test server URL and token + _, _, err := runCLI(cfgPath, []string{"glovo", "config", "set", "--base-url", srv.URL}, "") + if err != nil { + t.Fatalf("config set: %v", err) + } + _, _, err = runCLI(cfgPath, []string{"glovo", "session", "test-token"}, "") + if err != nil { + t.Fatalf("session: %v", err) + } + + // Test history command + out, _, err := runCLI(cfgPath, []string{"glovo", "history"}, "") + if err != nil { + t.Fatalf("history: %v out=%s", err, out) + } + if !strings.Contains(out, "Test Restaurant") { + t.Fatalf("restaurant not found in output: %s", out) + } + if !strings.Contains(out, "10,00 EUR") { + t.Fatalf("price not found in output: %s", out) + } +} + +func TestGlovoCLI_Me(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.json") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/me" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id": 12345, + "type": "Customer", + "urn": "glv:customer:test", + "name": "Test User", + "email": "test@example.com", + "preferredCityCode": "MAD", + "preferredLanguage": "en", + "deliveredOrdersCount": 5 + }`)) + })) + defer srv.Close() + + // Set config + _, _, err := runCLI(cfgPath, []string{"glovo", "config", "set", "--base-url", srv.URL}, "") + if err != nil { + t.Fatalf("config set: %v", err) + } + _, _, err = runCLI(cfgPath, []string{"glovo", "session", "test-token"}, "") + if err != nil { + t.Fatalf("session: %v", err) + } + + // Test me command + out, _, err := runCLI(cfgPath, []string{"glovo", "me"}, "") + if err != nil { + t.Fatalf("me: %v out=%s", err, out) + } + if !strings.Contains(out, "Test User") { + t.Fatalf("name not found in output: %s", out) + } + if !strings.Contains(out, "test@example.com") { + t.Fatalf("email not found in output: %s", out) + } +} + +func TestGlovoCLI_MissingToken(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.json") + + // Try to run history without token + _, _, err := runCLI(cfgPath, []string{"glovo", "history"}, "") + if err == nil { + t.Fatalf("expected error when token missing") + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 290b2a2..845b633 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -39,6 +39,7 @@ func newRoot() *cobra.Command { cmd.AddCommand(newFoodoraCmd(st)) cmd.AddCommand(newDeliverooCmd(st)) + cmd.AddCommand(newGlovoCmd(st)) return cmd } diff --git a/internal/cli/state.go b/internal/cli/state.go index 3e69551..9b8e01b 100644 --- a/internal/cli/state.go +++ b/internal/cli/state.go @@ -17,6 +17,8 @@ func (s *state) foodora() *config.FoodoraConfig { return s.cfg.Foodora() } func (s *state) deliveroo() *config.DeliverooConfig { return s.cfg.Deliveroo() } +func (s *state) glovo() *config.GlovoConfig { return s.cfg.Glovo() } + func (s *state) load() error { if s.configPath == "" { p, err := config.DefaultPath() diff --git a/internal/config/config.go b/internal/config/config.go index 3197c70..c33d118 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ type Config struct { type Providers struct { Foodora *FoodoraConfig `json:"foodora,omitempty"` Deliveroo *DeliverooConfig `json:"deliveroo,omitempty"` + Glovo *GlovoConfig `json:"glovo,omitempty"` } type FoodoraConfig struct { @@ -45,6 +46,17 @@ type DeliverooConfig struct { BaseURL string `json:"base_url,omitempty"` } +type GlovoConfig struct { + BaseURL string `json:"base_url,omitempty"` + AccessToken string `json:"access_token,omitempty"` + DeviceURN string `json:"device_urn,omitempty"` + CityCode string `json:"city_code,omitempty"` + CountryCode string `json:"country_code,omitempty"` + Language string `json:"language,omitempty"` + Latitude float64 `json:"latitude,omitempty"` + Longitude float64 `json:"longitude,omitempty"` +} + func DefaultPath() (string, error) { dir, err := os.UserConfigDir() if err != nil { @@ -157,6 +169,15 @@ func (c *Config) Deliveroo() *DeliverooConfig { return c.Providers.Deliveroo } +func (c *Config) Glovo() *GlovoConfig { + if c.Providers.Glovo == nil { + c.Providers.Glovo = &GlovoConfig{ + BaseURL: "https://api.glovoapp.com", + } + } + return c.Providers.Glovo +} + func (c FoodoraConfig) HasSession() bool { return c.AccessToken != "" && c.RefreshToken != "" } diff --git a/internal/glovo/client.go b/internal/glovo/client.go new file mode 100644 index 0000000..3c19738 --- /dev/null +++ b/internal/glovo/client.go @@ -0,0 +1,263 @@ +package glovo + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Client is a Glovo API client +type Client struct { + baseURL *url.URL + http *http.Client + accessToken string + deviceURN string + cityCode string + countryCode string + languageCode string + sessionID string + latitude float64 + longitude float64 +} + +// Options configures a new Glovo client +type Options struct { + BaseURL string + AccessToken string + DeviceURN string + CityCode string + CountryCode string + Language string + Latitude float64 + Longitude float64 +} + +// New creates a new Glovo API client +func New(opts Options) (*Client, error) { + if opts.AccessToken == "" { + return nil, errors.New("access token not set (run `ordercli glovo session `)") + } + + baseURL := opts.BaseURL + if baseURL == "" { + baseURL = "https://api.glovoapp.com" + } + + u, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + if !strings.HasSuffix(u.Path, "/") { + u.Path += "/" + } + + deviceURN := opts.DeviceURN + if deviceURN == "" { + deviceURN = "glv:device:" + newUUID() + } + + sessionID := newUUID() + + lang := opts.Language + if lang == "" { + lang = "en" + } + + return &Client{ + baseURL: u, + http: &http.Client{Timeout: 20 * time.Second}, + accessToken: opts.AccessToken, + deviceURN: deviceURN, + cityCode: opts.CityCode, + countryCode: opts.CountryCode, + languageCode: lang, + sessionID: sessionID, + latitude: opts.Latitude, + longitude: opts.Longitude, + }, nil +} + +// setHeaders sets all required Glovo API headers +func (c *Client) setHeaders(req *http.Request) { + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Authorization", "Bearer "+c.accessToken) + + // Required glovo-* headers + req.Header.Set("glovo-api-version", "14") + req.Header.Set("glovo-app-context", "web") + req.Header.Set("glovo-app-development-state", "prod") + req.Header.Set("glovo-app-platform", "web") + req.Header.Set("glovo-app-type", "customer") + req.Header.Set("glovo-app-version", "v1.1782.0") + req.Header.Set("glovo-client-info", "web-customer-web-react/v1.1782.0 project:customer-web") + + // Location headers + if c.latitude != 0 { + req.Header.Set("glovo-delivery-location-latitude", strconv.FormatFloat(c.latitude, 'f', -1, 64)) + } + if c.longitude != 0 { + req.Header.Set("glovo-delivery-location-longitude", strconv.FormatFloat(c.longitude, 'f', -1, 64)) + } + req.Header.Set("glovo-delivery-location-accuracy", "0") + req.Header.Set("glovo-delivery-location-timestamp", strconv.FormatInt(time.Now().UnixMilli(), 10)) + + // Device and session + req.Header.Set("glovo-device-urn", c.deviceURN) + req.Header.Set("glovo-dynamic-session-id", c.sessionID) + + // Language and location + req.Header.Set("glovo-language-code", c.languageCode) + if c.cityCode != "" { + req.Header.Set("glovo-location-city-code", c.cityCode) + } + if c.countryCode != "" { + req.Header.Set("glovo-location-country-code", c.countryCode) + } + + // Perseus (tracking) headers + perseusClientID := newUUID() + req.Header.Set("glovo-perseus-client-id", perseusClientID) + req.Header.Set("glovo-perseus-consent", "essential") + req.Header.Set("glovo-perseus-session-id", c.sessionID) + req.Header.Set("glovo-perseus-session-timestamp", strconv.FormatInt(time.Now().UnixMilli(), 10)) + + // Request tracking + req.Header.Set("glovo-request-id", newUUID()) + req.Header.Set("glovo-request-ttl", "7500") +} + +// newUUID generates a UUIDv4-ish string +func newUUID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("gen-%d", time.Now().UnixNano()) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], + b[4:6], + b[6:8], + b[8:10], + b[10:16], + ) +} + +// getJSON performs a GET request and decodes the JSON response. +func (c *Client) getJSON(ctx context.Context, path string, query url.Values, out any) error { + u := c.baseURL.ResolveReference(&url.URL{Path: path}) + if len(query) > 0 { + u.RawQuery = query.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return err + } + c.setHeaders(req) + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &HTTPError{ + Method: req.Method, + URL: req.URL.String(), + StatusCode: resp.StatusCode, + Body: body, + } + } + + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("%s: decode JSON: %w", path, err) + } + return nil +} + +// OrderHistory fetches the order history list +func (c *Client) OrderHistory(ctx context.Context, offset, limit int) (OrdersResponse, error) { + if limit <= 0 { + limit = 12 + } + + query := url.Values{ + "offset": {strconv.Itoa(offset)}, + "limit": {strconv.Itoa(limit)}, + } + + var out OrdersResponse + if err := c.getJSON(ctx, "v3/customer/orders-list", query, &out); err != nil { + return OrdersResponse{}, err + } + return out, nil +} + +// ActiveOrders returns orders that are currently active (being delivered) +func (c *Client) ActiveOrders(ctx context.Context) ([]Order, error) { + resp, err := c.OrderHistory(ctx, 0, 20) + if err != nil { + return nil, err + } + + var active []Order + for _, o := range resp.Orders { + // Active orders have layoutType "ACTIVE_ORDER" or similar non-inactive types + if o.LayoutType != "INACTIVE_ORDER" { + active = append(active, o) + } + } + return active, nil +} + +// Baskets fetches the user's shopping carts +func (c *Client) Baskets(ctx context.Context, customerID int) (BasketsResponse, error) { + path := fmt.Sprintf("v1/authenticated/customers/%d/baskets", customerID) + + var out BasketsResponse + if err := c.getJSON(ctx, path, nil, &out); err != nil { + return nil, err + } + return out, nil +} + +// Me fetches the current user's profile +func (c *Client) Me(ctx context.Context) (UserResponse, error) { + var out UserResponse + if err := c.getJSON(ctx, "v3/me", nil, &out); err != nil { + return UserResponse{}, err + } + return out, nil +} + +// GetOrder fetches a single order by ID from history +func (c *Client) GetOrder(ctx context.Context, orderID int) (Order, error) { + // Glovo doesn't have a direct single-order endpoint, so we search history + resp, err := c.OrderHistory(ctx, 0, 50) + if err != nil { + return Order{}, err + } + + for _, o := range resp.Orders { + if o.OrderID == orderID { + return o, nil + } + } + + return Order{}, fmt.Errorf("order %d not found in recent history", orderID) +} diff --git a/internal/glovo/errors.go b/internal/glovo/errors.go new file mode 100644 index 0000000..4d32035 --- /dev/null +++ b/internal/glovo/errors.go @@ -0,0 +1,24 @@ +package glovo + +import "fmt" + +// HTTPError represents an HTTP error response from the Glovo API. +type HTTPError struct { + Method string + URL string + StatusCode int + Body []byte +} + +func (e *HTTPError) Error() string { + body := string(e.Body) + if len(body) > 300 { + body = body[:300] + "..." + } + return fmt.Sprintf("%s %s: HTTP %d: %s", e.Method, e.URL, e.StatusCode, body) +} + +// IsUnauthorized returns true if the error is an authentication error. +func (e *HTTPError) IsUnauthorized() bool { + return e.StatusCode == 401 || e.StatusCode == 403 +} diff --git a/internal/glovo/models.go b/internal/glovo/models.go new file mode 100644 index 0000000..d4220d4 --- /dev/null +++ b/internal/glovo/models.go @@ -0,0 +1,152 @@ +package glovo + +// OrdersResponse represents the response from /v3/customer/orders-list +type OrdersResponse struct { + Pagination Pagination `json:"pagination"` + Orders []Order `json:"orders"` + Rows any `json:"rows"` +} + +// Pagination contains pagination info for order list +type Pagination struct { + CurrentLimit int `json:"currentLimit"` + Next *string `json:"next"` +} + +// Order represents a single order in the history +type Order struct { + OrderID int `json:"orderId"` + OrderURN string `json:"orderUrn"` + Image Image `json:"image"` + Content Content `json:"content"` + Footer Footer `json:"footer"` + Style string `json:"style"` + LayoutType string `json:"layoutType"` + IsNewOrderTrackingEnabled bool `json:"isNewOrderTrackingEnabled"` + CourierName *string `json:"courierName"` +} + +// Image holds light/dark mode image IDs +type Image struct { + LightImageID string `json:"lightImageId"` + DarkImageID string `json:"darkImageId"` +} + +// Content contains order display content +type Content struct { + Title string `json:"title"` + Body []ContentBody `json:"body"` +} + +// ContentBody is a single content block +type ContentBody struct { + Type string `json:"type"` + Data string `json:"data"` +} + +// Footer contains order footer info (price, status) +type Footer struct { + Left *FooterItem `json:"left"` + Right *FooterItem `json:"right"` +} + +// FooterItem is a footer element +type FooterItem struct { + Type string `json:"type"` + Data any `json:"data"` // Can be string or object (button) +} + +// DataString returns Data as string if it is one, otherwise empty +func (f *FooterItem) DataString() string { + if s, ok := f.Data.(string); ok { + return s + } + return "" +} + +// BasketsResponse represents the response from /v1/authenticated/customers/{id}/baskets +type BasketsResponse []Basket + +// Basket represents a shopping cart for a store +type Basket struct { + StoreID int `json:"storeId"` + StoreAddressID int `json:"storeAddressId"` + StoreName string `json:"storeName"` + StoreSlug string `json:"storeSlug"` + Products []BasketItem `json:"products"` + SubTotal float64 `json:"subTotal"` + DeliveryFee float64 `json:"deliveryFee"` + ServiceFee float64 `json:"serviceFee"` + SmallOrderFee float64 `json:"smallOrderFee"` + Total float64 `json:"total"` + Currency string `json:"currency"` + MinOrderValue float64 `json:"minOrderValue"` + IsMinOrderMet bool `json:"isMinOrderMet"` +} + +// BasketItem represents an item in the cart +type BasketItem struct { + ID int `json:"id"` + ProductID int `json:"productId"` + Name string `json:"name"` + Description string `json:"description"` + Quantity int `json:"quantity"` + UnitPrice float64 `json:"unitPrice"` + TotalPrice float64 `json:"totalPrice"` +} + +// UserResponse represents the response from /v3/me +type UserResponse struct { + ID int `json:"id"` + Type string `json:"type"` + URN string `json:"urn"` + Name string `json:"name"` + Picture *string `json:"picture"` + Email string `json:"email"` + Description *string `json:"description"` + FacebookID *string `json:"facebookId"` + PreferredCityCode string `json:"preferredCityCode"` + PreferredLanguage string `json:"preferredLanguage"` + PreferredLanguageRegion string `json:"preferredLanguageRegion"` + Locale string `json:"locale"` + DeviceURN *string `json:"deviceUrn"` + AnalyticsID *string `json:"analyticsId"` + MediaCampaign *string `json:"mediaCampaign"` + MediaSource *string `json:"mediaSource"` + OS *string `json:"os"` + DeliveredOrdersCount int `json:"deliveredOrdersCount"` + PhoneNumber *PhoneNumber `json:"phoneNumber"` + CompanyDetail *string `json:"companyDetail"` + VirtualBalance *Balance `json:"virtualBalance"` + FreeOrders int `json:"freeOrders"` + PaymentMethod string `json:"paymentMethod"` + PaymentWay string `json:"paymentWay"` + CurrentCard *string `json:"currentCard"` + AccumulatedDebt float64 `json:"accumulatedDebt"` + Defaulter bool `json:"defaulter"` + Gender *string `json:"gender"` + AgeMin *int `json:"ageMin"` + AgeMax *int `json:"ageMax"` + Birthday *string `json:"birthday"` + PrivacySettings any `json:"privacySettings"` + Permissions []Permission `json:"permissions"` + DataPrivacyEnabled bool `json:"dataPrivacyEnabled"` +} + +// PhoneNumber represents a phone number with country code +type PhoneNumber struct { + Number string `json:"number"` + CountryCode *string `json:"countryCode"` +} + +// Balance represents virtual balance +type Balance struct { + Balance float64 `json:"balance"` +} + +// Permission represents a user permission setting +type Permission struct { + Type string `json:"type"` + Title string `json:"title"` + Enabled bool `json:"enabled"` +} From 41eb0a67de942122a8a60e4cdf46a4f62bedc679 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 16:51:22 +0100 Subject: [PATCH 2/2] fix: harden glovo session handling --- CHANGELOG.md | 1 + README.md | 18 +++++++ internal/cli/glovo_cmd.go | 9 +++- internal/cli/glovo_cmd_test.go | 42 +++++++++++++-- internal/glovo/client.go | 7 ++- internal/glovo/client_test.go | 94 ++++++++++++++++++++++++++++++++++ 6 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 internal/glovo/client_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c287761..9ea1bdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,4 +21,5 @@ - Reorder: preview by default; `orders/{orderCode}/reorder` with `--confirm` (adds to cart; address selectable) - Deliveroo (basic/WIP): `deliveroo history` (requires `DELIVEROO_BEARER_TOKEN`) - Deliveroo: accept numeric `order_number` values returned by the UK order history API. +- Glovo (basic/WIP): config, token session, history, active orders, order details, cart, and profile commands - Tests: reorder + redaction regressions diff --git a/README.md b/README.md index fa8a3af..5df7ba0 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Providers: - `foodora` (working) - `deliveroo` (work in progress; requires `DELIVEROO_BEARER_TOKEN`) +- `glovo` (work in progress; requires a browser access token) Concepts (shared CLI UX; provider-specific implementations): - `history` (past orders) @@ -160,6 +161,23 @@ export DELIVEROO_COOKIE='...' # optional `orders` looks for the most recent Deliveroo status URL in Atlas or Chrome history when no bearer token is present, then renders the page in headless Chromium and extracts the order details. +## glovo (WIP) + +Requires a valid Glovo browser access token. The CLI stores the token redacted in +`config show` and persists one generated Glovo device URN for repeat requests. + +```sh +./ordercli glovo config set --city-code MAD --country-code ES --language en +./ordercli glovo config set --lat 40.4168 --lon -3.7038 +./ordercli glovo session '' +./ordercli glovo history +./ordercli glovo order +./ordercli glovo orders +./ordercli glovo cart +./ordercli glovo me +./ordercli glovo logout +``` + ## Safety This talks to private APIs. Use at your own risk; rate limits / bot protection may block requests. diff --git a/internal/cli/glovo_cmd.go b/internal/cli/glovo_cmd.go index e4cae33..98eb7df 100644 --- a/internal/cli/glovo_cmd.go +++ b/internal/cli/glovo_cmd.go @@ -29,6 +29,10 @@ func newGlovoCmd(st *state) *cobra.Command { func newGlovoClient(st *state) (*glovo.Client, error) { cfg := st.glovo() + if cfg.DeviceURN == "" { + cfg.DeviceURN = glovo.NewDeviceURN() + st.markDirty() + } return glovo.New(glovo.Options{ BaseURL: cfg.BaseURL, AccessToken: cfg.AccessToken, @@ -66,10 +70,13 @@ func newGlovoConfigShowCmd(st *state) *cobra.Command { fmt.Fprintf(cmd.OutOrStdout(), "latitude=%v\n", cfg.Latitude) fmt.Fprintf(cmd.OutOrStdout(), "longitude=%v\n", cfg.Longitude) if cfg.AccessToken != "" { - fmt.Fprintf(cmd.OutOrStdout(), "access_token=%s...\n", cfg.AccessToken[:min(20, len(cfg.AccessToken))]) + fmt.Fprintf(cmd.OutOrStdout(), "access_token=***\n") } else { fmt.Fprintf(cmd.OutOrStdout(), "access_token=(not set)\n") } + if cfg.DeviceURN != "" { + fmt.Fprintf(cmd.OutOrStdout(), "device_urn=*** (stored)\n") + } }, } } diff --git a/internal/cli/glovo_cmd_test.go b/internal/cli/glovo_cmd_test.go index 886f0bc..a8fd0ba 100644 --- a/internal/cli/glovo_cmd_test.go +++ b/internal/cli/glovo_cmd_test.go @@ -1,11 +1,15 @@ package cli import ( + "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" + + "github.com/steipete/ordercli/internal/config" ) func TestGlovoCLI_ConfigSetAndShow(t *testing.T) { @@ -49,24 +53,36 @@ func TestGlovoCLI_SessionCommand(t *testing.T) { t.Fatalf("unexpected output: %s", out) } - // Verify token is visible in config show (truncated with ...) + // Verify token is redacted in config show. out, _, err = runCLI(cfgPath, []string{"glovo", "config", "show"}, "") if err != nil { t.Fatalf("config show: %v", err) } - if !strings.Contains(out, "access_token=test-token-12345...") { - t.Fatalf("token not visible in config: %s", out) + if !strings.Contains(out, "access_token=***") { + t.Fatalf("token not redacted in config: %s", out) + } + if strings.Contains(out, "test-token-12345") { + t.Fatalf("token leaked in config: %s", out) } } func TestGlovoCLI_History(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "config.json") + var deviceURNs []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Check path if !strings.HasPrefix(r.URL.Path, "/v3/customer/orders-list") { t.Errorf("unexpected path: %s", r.URL.Path) } + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("authorization=%q", got) + } + deviceURN := r.Header.Get("glovo-device-urn") + if !strings.HasPrefix(deviceURN, "glv:device:") { + t.Errorf("glovo-device-urn=%q", deviceURN) + } + deviceURNs = append(deviceURNs, deviceURN) // Return mock order response w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ @@ -105,6 +121,26 @@ func TestGlovoCLI_History(t *testing.T) { if !strings.Contains(out, "10,00 EUR") { t.Fatalf("price not found in output: %s", out) } + + // Generated device URNs should be persisted and reused across invocations. + out, _, err = runCLI(cfgPath, []string{"glovo", "history"}, "") + if err != nil { + t.Fatalf("history second run: %v out=%s", err, out) + } + if len(deviceURNs) != 2 || deviceURNs[0] == "" || deviceURNs[0] != deviceURNs[1] { + t.Fatalf("device URN not reused: %#v", deviceURNs) + } + raw, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read config: %v", err) + } + var cfg config.Config + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("decode config: %v", err) + } + if cfg.Providers.Glovo == nil || cfg.Providers.Glovo.DeviceURN != deviceURNs[0] { + t.Fatalf("device URN not persisted: config=%#v headers=%#v", cfg.Providers.Glovo, deviceURNs) + } } func TestGlovoCLI_Me(t *testing.T) { diff --git a/internal/glovo/client.go b/internal/glovo/client.go index 3c19738..1fb3aa9 100644 --- a/internal/glovo/client.go +++ b/internal/glovo/client.go @@ -61,7 +61,7 @@ func New(opts Options) (*Client, error) { deviceURN := opts.DeviceURN if deviceURN == "" { - deviceURN = "glv:device:" + newUUID() + deviceURN = NewDeviceURN() } sessionID := newUUID() @@ -151,6 +151,11 @@ func newUUID() string { ) } +// NewDeviceURN returns a Glovo device URN suitable for persisting in config. +func NewDeviceURN() string { + return "glv:device:" + newUUID() +} + // getJSON performs a GET request and decodes the JSON response. func (c *Client) getJSON(ctx context.Context, path string, query url.Values, out any) error { u := c.baseURL.ResolveReference(&url.URL{Path: path}) diff --git a/internal/glovo/client_test.go b/internal/glovo/client_test.go new file mode 100644 index 0000000..0a7b941 --- /dev/null +++ b/internal/glovo/client_test.go @@ -0,0 +1,94 @@ +package glovo + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" +) + +func TestNewDeviceURNFormat(t *testing.T) { + got := NewDeviceURN() + if !regexp.MustCompile(`^glv:device:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).MatchString(got) { + t.Fatalf("unexpected device URN: %q", got) + } +} + +func TestClientSetsHeadersAndQuery(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/customer/orders-list" { + t.Fatalf("path=%s", r.URL.Path) + } + if r.URL.Query().Get("offset") != "4" || r.URL.Query().Get("limit") != "7" { + t.Fatalf("query=%s", r.URL.RawQuery) + } + assertHeader := func(name, want string) { + t.Helper() + if got := r.Header.Get(name); got != want { + t.Fatalf("%s=%q want %q", name, got, want) + } + } + assertHeader("Authorization", "Bearer tok") + assertHeader("glovo-device-urn", "glv:device:fixed") + assertHeader("glovo-location-city-code", "MAD") + assertHeader("glovo-location-country-code", "ES") + assertHeader("glovo-language-code", "es") + assertHeader("glovo-delivery-location-latitude", "40.4168") + assertHeader("glovo-delivery-location-longitude", "-3.7038") + if got := r.Header.Get("glovo-request-id"); got == "" { + t.Fatal("missing request id") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"pagination":{"currentLimit":7},"orders":[]}`)) + })) + defer srv.Close() + + c, err := New(Options{ + BaseURL: srv.URL, + AccessToken: "tok", + DeviceURN: "glv:device:fixed", + CityCode: "MAD", + CountryCode: "ES", + Language: "es", + Latitude: 40.4168, + Longitude: -3.7038, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + resp, err := c.OrderHistory(context.Background(), 4, 7) + if err != nil { + t.Fatalf("OrderHistory: %v", err) + } + if resp.Pagination.CurrentLimit != 7 { + t.Fatalf("current limit=%d", resp.Pagination.CurrentLimit) + } +} + +func TestClientHTTPErrorIncludesStatusAndBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, strings.Repeat("denied", 80), http.StatusUnauthorized) + })) + defer srv.Close() + + c, err := New(Options{BaseURL: srv.URL, AccessToken: "tok"}) + if err != nil { + t.Fatalf("New: %v", err) + } + + _, err = c.Me(context.Background()) + var httpErr *HTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("err=%T %[1]v", err) + } + if !httpErr.IsUnauthorized() || httpErr.StatusCode != http.StatusUnauthorized { + t.Fatalf("unexpected HTTP error: %#v", httpErr) + } + if got := httpErr.Error(); !strings.Contains(got, "HTTP 401") || !strings.Contains(got, "denied") || len(got) > 380 { + t.Fatalf("unexpected error string: %q", got) + } +}