diff --git a/README.md b/README.md index 4714272..b0dfbd3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ It will even keep reminding the participants to pay until they've marked themsel * Automatic monitoring of participants' ordered items and sending how much each participant has to pay, including delivery rate * It will try to automatically match the Wolt user to a Slack user and tag the relevant user. In case no matching Slack user is found, an admin can add a custom user with `/add-user` command * Per-order debts reminders +* Send delivery progress emoji art, as well as a "get ready" message when the delivery is approaching ## Installation To install, you need an endpoint running Bolt server and a Slack app. @@ -38,4 +39,9 @@ Here are the basic steps to install Bolt: ##### Message sent to the user ![example_removed](docs/assets/examples/debt_removed.png) ##### Message sent to the host -![example_removed](docs/assets/examples/paid_host.png) \ No newline at end of file +![example_removed](docs/assets/examples/paid_host.png) + +### Delivery progress and a "get ready" message +![Delivery progress example](docs/assets/examples/delivery_progress.png) +> [!TIP] +> Change the destination icon to your company's logo using the `ORDER_DESTINATION_EMOJI` configuration. \ No newline at end of file diff --git a/bot/slack/slack.go b/bot/slack/slack.go index 1c38710..db4813a 100644 --- a/bot/slack/slack.go +++ b/bot/slack/slack.go @@ -70,6 +70,16 @@ func (c *Client) SendMessage(receiver, event, messageID string) (string, error) return ts, nil } +func (c *Client) EditMessage(receiver, event, messageID string) error { + options := []slack.MsgOption{slack.MsgOptionText(event, false)} + + _, _, _, err := c.UpdateMessage(receiver, messageID, options...) + if err != nil { + return fmt.Errorf("editing message %s: %w", messageID, err) + } + return nil +} + func (c *Client) AddReaction(receiver, messageID, reaction string) error { if err := c.Client.AddReaction(reaction, slack.ItemRef{ Channel: receiver, diff --git a/docs/assets/examples/delivery_progress.png b/docs/assets/examples/delivery_progress.png new file mode 100644 index 0000000..c017996 Binary files /dev/null and b/docs/assets/examples/delivery_progress.png differ diff --git a/docs/configuration.md b/docs/configuration.md index dde46ec..d1e69df 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,6 +9,9 @@ Bolt is configured using environment variables * `DONT_JOIN_AFTER` - If defined, Bolt won't join orders after that time. Time is defined in HH:MM format. Default is None (will always join). * `DONT_JOIN_AFTER_TZ` - Defining the timezone for the hour defined in `DONT_JOIN_AFTER`. For example: `Europe/London`. Default is none (will be the local time where Bolt is running). * `ORDER_READY_TIMEOUT` - Timeout for waiting for the Wolt group order to be sent in duration format (ex: 1m/1h). After that duration, Bolt will stop tracking that order. Default is 40m (40 minutes). +* `ORDER_DONE_TIMEOUT` - Timeout for waiting for the Wolt group order to be delivered after payment. After that duration, Bolt will stop tracking that order. Default is 3h (3 hours). +* `TIME_TILL_GET_READY_MESSAGE` - Defines how long before the delivery ETA the "get ready" message will be sent. Default is 7m (7 minutes). +* `ORDER_DESTINATION_EMOJI` - The emoji used to represent the order's destination in the progress message. Default is :house:. * `DEBT_REMINDER_INTERVAL` - Time to wait between each reminder of unpaid debt in duration format. Default is 3h (3 hours). * `DEBT_MAXIMUM_DURATION` - Maximum duration for keep reminding about unpaid debt in duration format. After that time, no more reminders will be sent. Default is 24h (24 hours). * `WAIT_BETWEEN_STATUS_CHECK` - Duration between polling for Wolt order status in duration format. Default is 20s (20 seconds). diff --git a/service/debt.go b/service/debt.go index d05587c..2ec16b0 100644 --- a/service/debt.go +++ b/service/debt.go @@ -58,7 +58,7 @@ func (h *Service) HandleReactionAdded(req ReactionAddRequest) (string, error) { return "", nil } if hostUser.TransportID != req.FromUserID { - h.informEvent(req.FromUserID, fmt.Sprintf("Nice try :stuck_out_tongue_winking_eye: Only the host (<@%s>) can cancel debts for this order", hostForOrder), "", "") + _, _ = h.informEvent(req.FromUserID, fmt.Sprintf("Nice try :stuck_out_tongue_winking_eye: Only the host (<@%s>) can cancel debts for this order", hostForOrder), "", "") return "", nil } if err := h.removeAllDebtsForOrder(parsedID.ID, "the host requested to cancel debts tracking"); err != nil { @@ -122,7 +122,7 @@ func (h *Service) remindDebt(debt *debtDomain.Debt) error { return nil } - h.informEvent(borrower.TransportID, + _, _ = h.informEvent(borrower.TransportID, fmt.Sprintf("Reminder, you should pay %.2f nis to <@%s> for Wolt order ID %s.\n"+ "If you paid, you can mark yourself as paid by adding :%s: reaction to this message \\ the original rates message.", debt.Amount, debt.LenderID, debt.OrderID, MarkAsPaidReaction), @@ -149,11 +149,11 @@ func (h *Service) addDebts(initiatedTransport, orderID string, rates GroupRate, } if rates.HostUser == nil { - h.informEvent(initiatedTransport, fmt.Sprintf("I didn't find the user of the host (%s), I won't track debts for order %s", rates.HostWoltUser, orderID), "", messageID) + _, _ = h.informEvent(initiatedTransport, fmt.Sprintf("I didn't find the user of the host (%s), I won't track debts for order %s", rates.HostWoltUser, orderID), "", messageID) return nil } - h.informEvent(initiatedTransport, + _, _ = h.informEvent(initiatedTransport, fmt.Sprintf("I'll keep reminding you to pay, when you pay you can react with :%s: to the rates message and I'll stop bothering you.\n"+ "<@%s>, as the host, you can react with :%s: to the rates message to cancel debts tracking for Wolt order ID %s", MarkAsPaidReaction, rates.HostUser.TransportID, HostRemoveDebts, orderID), @@ -166,7 +166,7 @@ func (h *Service) addDebts(initiatedTransport, orderID string, rates GroupRate, } if rate.User == nil { - h.informEvent(initiatedTransport, fmt.Sprintf("I won't track %q payment because I can't find his user.", rate.WoltName), "", messageID) + _, _ = h.informEvent(initiatedTransport, fmt.Sprintf("I won't track %q payment because I can't find his user.", rate.WoltName), "", messageID) continue } if err := h.createDebt(rate.Amount, initiatedTransport, orderID, messageID, rate.User, rates.HostUser); err != nil { @@ -213,7 +213,7 @@ func (h *Service) removeAllDebtsForOrder(orderID, reason string) error { } } - h.informEvent(lender, fmt.Sprintf("I removed all debts for order ID %s because %s", orderID, reason), "", "") + _, _ = h.informEvent(lender, fmt.Sprintf("I removed all debts for order ID %s because %s", orderID, reason), "", "") return nil } @@ -245,7 +245,7 @@ func (h *Service) markDebtAsPaid(orderID, reactedTransportID, initialChannel str return fmt.Errorf("remove debt: %w", err) } - h.informEvent(borrower.TransportID, fmt.Sprintf("OK! I removed your debt for order %s", debt.OrderID), "", "") + _, _ = h.informEvent(borrower.TransportID, fmt.Sprintf("OK! I removed your debt for order %s", debt.OrderID), "", "") // Notify in the initial channel of the wolt link message in case we will get error getting the host details recipient := initialChannel @@ -258,7 +258,7 @@ func (h *Service) markDebtAsPaid(orderID, reactedTransportID, initialChannel str messageID = "" } - h.informEvent(recipient, fmt.Sprintf("<@%s> marked himself as paid for order ID %s", borrower.TransportID, debt.OrderID), "", messageID) + _, _ = h.informEvent(recipient, fmt.Sprintf("<@%s> marked himself as paid for order ID %s", borrower.TransportID, debt.OrderID), "", messageID) return nil } diff --git a/service/delivery.go b/service/delivery.go new file mode 100644 index 0000000..dd277d0 --- /dev/null +++ b/service/delivery.go @@ -0,0 +1,125 @@ +package service + +import ( + "context" + "fmt" + "math" + "strings" + "time" + + "github.com/oriser/bolt/wolt" +) + +func (h *Service) buildProgressEmojiArt(startedAt time.Time, deliveryEta time.Time, timezone *time.Location) string { + const ( + numberOfSpacesBetweenTimes = 23 + numberOfSpacesBeforeDestinationEmoji = 3 + numberOfRoadTiles = 13 + roadTileAsciiArt = "_" + CourierEmoji = ":bike:" + VenueEmoji = ":cook:" + ) + + var sb strings.Builder + + // Use Slack's date formatting to display times at the recipient's timezone + deliveryEtaString := fmt.Sprintf("", deliveryEta.Unix(), deliveryEta.In(timezone).Format("15:04")) + startedAtString := fmt.Sprintf("", startedAt.Unix(), startedAt.In(timezone).Format("15:04")) + + // Due to Slack emoji constraints, the courier advances from right (venue) to left (destination) + firstLine := fmt.Sprintf( + "`%s`%s`%s`", + deliveryEtaString, + strings.Repeat(" ", numberOfSpacesBetweenTimes), + startedAtString) + sb.WriteString(firstLine + "\n") + + deliveryPercentage := time.Since(startedAt).Seconds() / deliveryEta.Sub(startedAt).Seconds() + numberOfRoadTilesBehindCourier := int(math.Round(deliveryPercentage * numberOfRoadTiles)) + secondLine := strings.Repeat(" ", numberOfSpacesBeforeDestinationEmoji) + + fmt.Sprintf(":%s:", h.cfg.OrderDestinationEmoji) + + strings.Repeat(roadTileAsciiArt, numberOfRoadTiles-numberOfRoadTilesBehindCourier) + + CourierEmoji + + strings.Repeat(roadTileAsciiArt, numberOfRoadTilesBehindCourier) + + VenueEmoji + sb.WriteString(secondLine) + + return sb.String() +} + +func (h *Service) updateDeliveryProgressMessage(initiatedTransport string, order *groupOrder, details *wolt.OrderDetails, ratesMessage string) error { + var err error + + if details.PurchaseDatetime.Equal(time.Unix(0, 0)) { + return nil + } + + var deliveryTime time.Time + var deliveryTimeExists bool + if details.IsDelivered() { + deliveryTime, deliveryTimeExists = details.Purchase.DeliveryStatusLog["delivered"] + if !deliveryTimeExists { + deliveryTime = time.Now() + } + } else if !details.DeliveryEta.Equal(time.Unix(0, 0)) { + deliveryTime = details.DeliveryEta + } else { + // No delivery ETA yet. Nothing to update. + return nil + } + + err = h.eventNotification.EditMessage( + initiatedTransport, + strings.TrimSuffix(ratesMessage, "\n")+"\n\n"+h.buildProgressEmojiArt(details.PurchaseDatetime, deliveryTime, order.venue.TimezoneLocation), + order.detailsMessageId) + if err != nil { + return fmt.Errorf("updating details message %s: %w", order.detailsMessageId, err) + } + + return err +} + +func (h *Service) monitorDelivery(initiatedTransport string, order *groupOrder, ctx context.Context, waitBetweenStatusCheck time.Duration, messageID string, ratesMessage string) error { + details, err := order.fetchDetails() + if err != nil { + return fmt.Errorf("get group details: %w", err) + } + + getReadyMessageSent := false + for details.Status != wolt.StatusCanceled { + err = h.updateDeliveryProgressMessage(initiatedTransport, order, details, ratesMessage) + if err != nil { + return err + } + + if details.IsDelivered() { + if !getReadyMessageSent { + _, _ = h.informEvent(initiatedTransport, "Delivery arrived", "", messageID) + getReadyMessageSent = true //nolint:ineffassign + } + return nil + } else if !details.DeliveryEta.Equal(time.Unix(0, 0)) { + timeToDelivery := time.Until(details.DeliveryEta) + if !getReadyMessageSent && timeToDelivery < h.cfg.TimeTillGetReadyMessage { + _, _ = h.informEvent(initiatedTransport, "Get ready, delivery coming soon", "", messageID) + getReadyMessageSent = true + } + } + + select { + case <-time.After(waitBetweenStatusCheck): + details, err = order.fetchDetails() + if err != nil { + return fmt.Errorf("get group details: %w", err) + } + case <-ctx.Done(): + return fmt.Errorf("context canceled while waiting for group to progress") + } + } + + if details.Status == wolt.StatusCanceled { + return fmt.Errorf("order canceled") + } + + return nil +} diff --git a/service/group.go b/service/group.go index 9649032..139fd7a 100644 --- a/service/group.go +++ b/service/group.go @@ -34,12 +34,13 @@ func (h *Service) joinGroupOrder(groupID string) (*groupOrder, error) { } type groupOrder struct { - id string - deliveryPrice int - woltGroup *wolt.Group - markedAsReady bool - details *wolt.OrderDetails - venue *wolt.Venue + id string + deliveryPrice int + woltGroup *wolt.Group + markedAsReady bool + details *wolt.OrderDetails + venue *wolt.Venue + detailsMessageId string } func (g *groupOrder) fetchDetails() (*wolt.OrderDetails, error) { @@ -73,16 +74,16 @@ func (g *groupOrder) MarkAsReady() error { return nil } -func (g *groupOrder) WaitUntilFinished(ctx context.Context, waitBetweenStatusCheck time.Duration) error { - details, err := g.fetchDetails() +func (h *Service) WaitUntilFinished(order *groupOrder, ctx context.Context) error { + details, err := order.fetchDetails() if err != nil { return fmt.Errorf("get group details: %w", err) } for details.Status == wolt.StatusActive { select { - case <-time.After(waitBetweenStatusCheck): - details, err = g.fetchDetails() + case <-time.After(h.cfg.WaitBetweenStatusCheck): + details, err = order.fetchDetails() if err != nil { return fmt.Errorf("get group details: %w", err) } diff --git a/service/rate.go b/service/rate.go index ab64ce9..5beb0a2 100644 --- a/service/rate.go +++ b/service/rate.go @@ -61,7 +61,7 @@ func (h *Service) HandleLinkMessage(req LinksRequest) (string, error) { log.Println("Already working on order", groupID.ID) return "", nil } - h.currentlyWorkingOrders.Store(groupID.ID, true) + h.currentlyWorkingOrders.Store(groupID.ID, nil) defer h.currentlyWorkingOrders.Delete(groupID.ID) groupRate, err := h.getRateForGroup(req.Channel, groupID.ID, req.MessageID) @@ -70,21 +70,40 @@ func (h *Service) HandleLinkMessage(req LinksRequest) (string, error) { return "", nil } if strings.Contains(err.Error(), "order canceled") { - h.informEvent(req.Channel, fmt.Sprintf("Order for group ID %s was canceled", groupID.ID), "", req.MessageID) + _, _ = h.informEvent(req.Channel, fmt.Sprintf("Order for group ID %s was canceled", groupID.ID), "", req.MessageID) return "", nil } log.Printf("Error getting rate for group %s: %v\n", groupID.ID, err) - h.informEvent(req.Channel, fmt.Sprintf("I had an error getting rate for group ID %s", groupID.ID), "", req.MessageID) + _, _ = h.informEvent(req.Channel, fmt.Sprintf("I had an error getting rate for group ID %s", groupID.ID), "", req.MessageID) return "", nil } + order, _ := h.currentlyWorkingOrders.Load(groupID.ID) + if order == nil { + return "", fmt.Errorf("order %s not initialized in map", groupID.ID) + } + ratesMessage := h.buildRatesMessage(groupRate, groupID.ID) - h.informEvent(req.Channel, ratesMessage, MarkAsPaidReaction, req.MessageID) + order.(*groupOrder).detailsMessageId, err = h.informEvent(req.Channel, ratesMessage, MarkAsPaidReaction, req.MessageID) + if err != nil { + return "", fmt.Errorf("failed sending details message: %w", err) + } if err := h.addDebts(req.Channel, groupID.ID, groupRate, req.MessageID); err != nil { log.Println(fmt.Sprintf("Error adding debts: %s", err.Error())) - h.informEvent(req.Channel, "I had an error adding debts, I won't track this order", "", req.MessageID) + _, _ = h.informEvent(req.Channel, "I had an error adding debts, I won't track this order", "", req.MessageID) + } + + ctx, cancel := context.WithTimeout(context.Background(), h.cfg.OrderDoneTimeout) + defer cancel() + if err = h.monitorDelivery(req.Channel, order.(*groupOrder), ctx, h.cfg.WaitBetweenStatusCheck, req.MessageID, ratesMessage); err != nil { + if strings.Contains(err.Error(), "context canceled while waiting") { + _, _ = h.informEvent(req.Channel, "Timed out waiting for order to be done", "", req.MessageID) + return "", nil + } + return "", fmt.Errorf("error in waiting for order to finish: %w", err) } + return "", nil } @@ -218,14 +237,15 @@ func (h *Service) getRateForGroup(receiver, groupID, messageID string) (groupRat if !shouldHandleOrder { msg = fmt.Sprintf("%s. But it's too late for me.. I won't track prices for this order :sleeping:", msg) } - if !h.informEvent(receiver, msg, "", messageID) { + if _, err := h.informEvent(receiver, msg, "", messageID); err != nil { return GroupRate{}, errWontJoin } order, err := h.joinGroupOrder(groupID) if err != nil { - h.informEvent(receiver, "I had an error joining the order", "", messageID) + _, _ = h.informEvent(receiver, "I had an error joining the order", "", messageID) return GroupRate{}, fmt.Errorf("join group order: %w", err) } + h.currentlyWorkingOrders.Store(groupID, order) defer func() { go h.saveOrderAsync(order, groupRate, receiver) @@ -240,7 +260,7 @@ func (h *Service) getRateForGroup(receiver, groupID, messageID string) (groupRat monitorCtx, monitorCancel := context.WithCancel(ctx) go h.monitorVenue(monitorCtx, order, receiver, messageID) - if err = order.WaitUntilFinished(ctx, h.cfg.WaitBetweenStatusCheck); err != nil { + if err = h.WaitUntilFinished(order, ctx); err != nil { monitorCancel() return GroupRate{}, fmt.Errorf("wait for group to finish: %w", err) } @@ -262,7 +282,7 @@ func (h *Service) getRateForGroup(receiver, groupID, messageID string) (groupRat deliveryRate, err := order.CalculateDeliveryRate() if err != nil { - h.informEvent(receiver, "I can't find the delivery rate, I'll publish the rates without including the delivery rate", "", messageID) + _, _ = h.informEvent(receiver, "I can't find the delivery rate, I'll publish the rates without including the delivery rate", "", messageID) log.Println("Error getting delivery rate:", err) return h.buildGroupRates(rates, details.Host, 0), nil } @@ -297,12 +317,12 @@ func (h *Service) monitorVenue(ctx context.Context, order *groupOrder, receiver, } if !venue.Online && online { - h.informEvent(receiver, ":red_circle: Pay attention. The venue went offline :(", "", initialMessageID) + _, _ = h.informEvent(receiver, ":red_circle: Pay attention. The venue went offline :(", "", initialMessageID) online = false } if venue.Online && !online { - h.informEvent(receiver, ":large_green_circle: The venue is back online :)", "", initialMessageID) + _, _ = h.informEvent(receiver, ":large_green_circle: The venue is back online :)", "", initialMessageID) online = true } diff --git a/service/service.go b/service/service.go index b16febf..e068d59 100644 --- a/service/service.go +++ b/service/service.go @@ -2,7 +2,6 @@ package service import ( "fmt" - "log" "sync" "time" @@ -13,11 +12,15 @@ import ( type EventNotification interface { SendMessage(receiver, event, messageID string) (string, error) + EditMessage(receiver, event, messageID string) error AddReaction(receiver, messageID, reaction string) error } type Config struct { TimeoutForReady time.Duration `env:"ORDER_READY_TIMEOUT" envDefault:"40m"` + OrderDoneTimeout time.Duration `env:"ORDER_DONE_TIMEOUT" envDefault:"3h"` + TimeTillGetReadyMessage time.Duration `env:"TIME_TILL_GET_READY_MESSAGE" envDefault:"7m"` + OrderDestinationEmoji string `env:"ORDER_DESTINATION_EMOJI" envDefault:"house"` TimeoutForDeliveryRate time.Duration `env:"GET_DELIVERY_RATE_TIMEOUT" envDefault:"10m"` WaitBetweenStatusCheck time.Duration `env:"WAIT_BETWEEN_STATUS_CHECK" envDefault:"20s"` DebtReminderInterval time.Duration `env:"DEBT_REMINDER_INTERVAL" envDefault:"3h"` @@ -90,22 +93,22 @@ func New(cfg Config, userStore user.Store, debtStore debt.Store, orderStore orde }, nil } -func (h *Service) informEvent(receiver, event, reactionEmoji, initialMessageID string) bool { +func (h *Service) informEvent(receiver, event, reactionEmoji, initialMessageID string) (string, error) { if h.eventNotification == nil { - return true + return "", fmt.Errorf("nil eventNotification") } messageID, err := h.eventNotification.SendMessage(receiver, event, initialMessageID) if err != nil { - log.Printf("Error informing event to receiver %q: %v\n", receiver, err) - return false + return "", fmt.Errorf("error replying to message %s: %w", receiver, err) } if reactionEmoji == "" { - return true + return messageID, nil } if err = h.eventNotification.AddReaction(receiver, messageID, reactionEmoji); err != nil { - log.Printf("Error adding reaction to message ID %s:%v\n", messageID, err) + return messageID, fmt.Errorf("error adding reaction to message %s: %w\n", messageID, err) } - return true + + return messageID, nil } diff --git a/wolt/details.go b/wolt/details.go index 13d40ac..0f359e2 100644 --- a/wolt/details.go +++ b/wolt/details.go @@ -37,6 +37,38 @@ func (p *Participant) Name() string { } type Status string +type DeliveryStatus string +type DeliveryStatusToTimeMap map[DeliveryStatus]time.Time + +func (deliveryStatusLog *DeliveryStatusToTimeMap) UnmarshalJSON(bytes []byte) error { + o := &([]struct { + Datetime struct { + DateUnix int64 `json:"$date"` + } `json:"datetime"` + Status DeliveryStatus `json:"status"` + }{}) + + err := json.Unmarshal(bytes, o) + if err != nil { + return fmt.Errorf("error unmarshalling DeliveryStatusToTimeMap %w", err) + } + + res := make(DeliveryStatusToTimeMap) + for _, entry := range *o { + if entry.Datetime.DateUnix == 0 || entry.Status == "" { + return fmt.Errorf("error parsing delivery status log entry %w", err) + } + + currentEntryTime := time.UnixMilli(entry.Datetime.DateUnix) + // Duplicate statuses shouldn't occur, but if they do, we take their latest timestamp + if existingTime, exists := res[entry.Status]; !exists || currentEntryTime.After(existingTime) { + res[entry.Status] = currentEntryTime + } + } + *deliveryStatusLog = res + + return nil +} func (s Status) Purchased() bool { return s == StatusPurchased || s == StatusPendingTrans @@ -53,8 +85,20 @@ type OrderDetails struct { } `json:"details"` HostID string `json:"host_id"` Participants []Participant `json:"participants"` + Purchase struct { + DeliveryEtaUnix struct { + DateUnix int64 `json:"$date"` + } `json:"delivery_eta"` + DeliveryStatus DeliveryStatus `json:"delivery_status"` + DeliveryStatusLog DeliveryStatusToTimeMap `json:"delivery_status_log"` + PurchaseDatetimeUnix struct { + DateUnix int64 `json:"$date"` + } `json:"purchase_datetime"` + } `json:"purchase"` CreatedAt time.Time `json:"-"` + DeliveryEta time.Time `json:"-"` + PurchaseDatetime time.Time `json:"-"` ParsedDeliveryCoordinate Coordinate `json:"-"` Host string `json:"-"` } @@ -66,6 +110,10 @@ const ( StatusPurchased Status = "purchased" ) +const ( + DeliveryStatusDelivered DeliveryStatus = "delivered" +) + const DeliveryCoordinatesPath = "details.delivery_info.location.coordinates.coordinates" func ParseOrderDetails(orderDetailsJSON []byte) (*OrderDetails, error) { @@ -88,6 +136,9 @@ func ParseOrderDetails(orderDetailsJSON []byte) (*OrderDetails, error) { } o.CreatedAt = time.UnixMilli(o.CreatedAtUnix.DateUnix) + o.DeliveryEta = time.UnixMilli(o.Purchase.DeliveryEtaUnix.DateUnix) + o.PurchaseDatetime = time.UnixMilli(o.Purchase.PurchaseDatetimeUnix.DateUnix) + return o, nil } @@ -117,3 +168,7 @@ func (o *OrderDetails) RateByPerson() (map[string]float64, error) { return output, nil } + +func (o *OrderDetails) IsDelivered() bool { + return o.Purchase.DeliveryStatus == DeliveryStatusDelivered +} diff --git a/wolt/venue.go b/wolt/venue.go index ac66839..ebebac9 100644 --- a/wolt/venue.go +++ b/wolt/venue.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "math" + "time" ) const ( @@ -23,13 +24,15 @@ type Venue struct { DeliverySpecs struct { DeliveryPricing PriceRanges `json:"delivery_pricing"` } `json:"delivery_specs"` - Names []VenueName `json:"name"` - Link string `json:"public_url"` - City string `json:"city"` - Online bool `json:"online"` + Names []VenueName `json:"name"` + Link string `json:"public_url"` + City string `json:"city"` + Timezone string `json:"timezone"` + Online bool `json:"online"` Name string - ParsedCoordinate Coordinate `json:"-"` + ParsedCoordinate Coordinate `json:"-"` + TimezoneLocation *time.Location `json:"-"` } type Coordinate struct { @@ -67,6 +70,11 @@ func ParseVenue(venuesJSON []byte) (*Venue, error) { return nil, fmt.Errorf("venue coordinate from array: %w", err) } + v.TimezoneLocation, err = time.LoadLocation(v.Timezone) + if err != nil { + return nil, fmt.Errorf("unexpected venue timezone: %w", err) + } + for _, name := range v.Names { if name.Lang == "en" || v.Name == "" { v.Name = name.Value