Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add delivery progress #13

Merged
merged 13 commits into from
Mar 24, 2024
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
![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.
10 changes: 10 additions & 0 deletions bot/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Binary file added docs/assets/examples/delivery_progress.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
16 changes: 8 additions & 8 deletions service/debt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down
125 changes: 125 additions & 0 deletions service/delivery.go
Original file line number Diff line number Diff line change
@@ -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("<!date^%d^{time}|%s>", deliveryEta.Unix(), deliveryEta.In(timezone).Format("15:04"))
startedAtString := fmt.Sprintf("<!date^%d^{time}|%s>", 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
}
21 changes: 11 additions & 10 deletions service/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down
42 changes: 31 additions & 11 deletions service/rate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading