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"` +}