diff --git a/Dockerfile b/Dockerfile index a0d90ae..d7c1fc0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,8 @@ FROM golang:1.23-bookworm as builder WORKDIR /go/src/bolt COPY . . -RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o /bolt cmd/main.go && chmod +x /bolt +# The "json1" build tag enables JSON SQL functions in go-sqlite3 +RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -tags json1 -o /bolt cmd/main.go && chmod +x /bolt FROM gcr.io/distroless/base COPY --from=builder /bolt /bolt diff --git a/README.md b/README.md index b240bed..8dd5e9f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ It will even keep reminding the participants to pay until they've marked themsel * Per-order debts reminders * Send delivery progress emoji art, as well as a "get ready" message when the delivery is approaching * Monitor closed venues and receive updates once they are open +* Send a monthly digest summarizing the past month's activity ## Installation To install, you need an endpoint running Bolt server and a Slack app. @@ -48,4 +49,7 @@ Here are the basic steps to install Bolt: > Change the destination icon to your company's logo using the `ORDER_DESTINATION_EMOJI` configuration. ### Monitor closed venues -![Venue monitoring example](docs/assets/examples/monitor_venue.png) \ No newline at end of file +![Venue monitoring example](docs/assets/examples/monitor_venue.png) + +### Bolt's monthly digest +![Monthly digest example](docs/assets/examples/monthly_digest.png) diff --git a/bot/slack/slack.go b/bot/slack/slack.go index 81e2b0f..e417d96 100644 --- a/bot/slack/slack.go +++ b/bot/slack/slack.go @@ -70,6 +70,18 @@ func (c *Client) SendMessage(receiver, event, messageID string) (string, error) return ts, nil } +func (c *Client) SendBlocksMessage(receiver string, blocks []slack.Block, messageID string) (string, error) { + options := []slack.MsgOption{slack.MsgOptionBlocks(blocks...), slack.MsgOptionDisableMediaUnfurl()} + if messageID != "" { + options = append(options, slack.MsgOptionTS(messageID)) + } + _, ts, err := c.PostMessage(receiver, options...) + if err != nil { + return "", fmt.Errorf("posting blocks message: %w", err) + } + return ts, nil +} + func (c *Client) EditMessage(receiver, event, messageID string) error { if messageID == "" { return fmt.Errorf("empty message ID") diff --git a/cmd/run/run.go b/cmd/run/run.go index 163c111..b22c5da 100644 --- a/cmd/run/run.go +++ b/cmd/run/run.go @@ -6,6 +6,8 @@ import ( "fmt" "log" + "github.com/robfig/cron/v3" + "github.com/caarlos0/env/v6" "github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/jmoiron/sqlx" @@ -28,6 +30,17 @@ func (c Config) String() string { return string(res) } +func startDigestScheduler(serviceHandler *service.Service) (*cron.Cron, error) { + scheduler := cron.New() + _, err := scheduler.AddFunc("@monthly", serviceHandler.SendMonthlyDigest) + if err != nil { + return nil, fmt.Errorf("error calling AddFunc: %w", err) + } + scheduler.Start() + + return scheduler, nil +} + func Run() error { cfg := Config{} if err := env.Parse(&cfg); err != nil { @@ -62,6 +75,12 @@ func Run() error { return fmt.Errorf("new service: %w", err) } + digestScheduler, err := startDigestScheduler(serviceHandler) + if err != nil { + return fmt.Errorf("starting digest scheduler: %w", err) + } + defer digestScheduler.Stop() + slackBot := slackClient.ServiceBot(serviceHandler) if err := slackBot.ListenAndServe(context.Background()); err != nil { return fmt.Errorf("ListenAndServe: %w", err) diff --git a/docs/assets/examples/monthly_digest.png b/docs/assets/examples/monthly_digest.png new file mode 100644 index 0000000..a17b00c Binary files /dev/null and b/docs/assets/examples/monthly_digest.png differ diff --git a/go.mod b/go.mod index 1e5744d..24c85af 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/oriser/bolt -go 1.18 +go 1.22 require ( github.com/Jeffail/gabs/v2 v2.6.0 @@ -39,6 +39,7 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sirupsen/logrus v1.8.1 // indirect go.uber.org/atomic v1.6.0 // indirect golang.org/x/crypto v0.27.0 // indirect diff --git a/go.sum b/go.sum index 87635b8..114e211 100644 --- a/go.sum +++ b/go.sum @@ -813,6 +813,8 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1 github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= diff --git a/order/order.go b/order/order.go index 55bbfd9..7cac016 100644 --- a/order/order.go +++ b/order/order.go @@ -19,6 +19,21 @@ type Participant struct { Amount float64 `json:"amount"` } +type VenueOrderCount struct { + VenueId string `db:"venue_id"` + VenueName string `db:"venue_name"` + OrderCount int `db:"order_count"` + VenueLink string `db:"venue_link"` + LastCreatedAt string `db:"last_created_at"` // The driver returns this column as a string +} + +type MouthsFedCount struct { + HostId string `db:"host_id"` + HostName string `db:"host"` + MouthsFedCount int `db:"mouths_fed_count"` + LastCreatedAt string `db:"last_created_at"` // The driver returns this column as a string +} + type Order struct { ID string `db:"id"` OriginalID string `db:"original_id"` @@ -37,4 +52,7 @@ type Order struct { type Store interface { SaveOrder(ctx context.Context, order *Order) error + GetVenuesWithMostOrders(startTime time.Time, limit uint64, channelId string, filteredVenueIds []string) ([]VenueOrderCount, error) + GetHostsWithMostMouthsFed(startTime time.Time, limit uint64, channelId string, filteredHostIds []string) ([]MouthsFedCount, error) + GetActiveChannelIds(lastDateConsideredActive time.Time) ([]string, error) } diff --git a/service/digest.go b/service/digest.go new file mode 100644 index 0000000..d1c8644 --- /dev/null +++ b/service/digest.go @@ -0,0 +1,215 @@ +package service + +import ( + "fmt" + "log" + "slices" + "time" + + "github.com/oriser/bolt/order" + "github.com/slack-go/slack" +) + +var dateOneMonthAgo time.Time +var numberOfDigestRows = uint64(5) // Hard-coded due to slack.SectionBlock limitations +var numberToEmojiMap = map[int]string{ + 1: ":one:", + 2: ":two:", + 3: ":three:", + 4: ":four:", + 5: ":five:", +} + +func buildTopVenuesMessageBlocks(monthlyTopVenues []order.VenueOrderCount, monthlyTopVenuesTotalCounts []order.VenueOrderCount) ([]slack.Block, error) { + venueIdToTotalOrderCount := make(map[string]int) + for _, venue := range monthlyTopVenuesTotalCounts { + venueIdToTotalOrderCount[venue.VenueId] = venue.OrderCount + } + + topVenuesHeader := slack.NewSectionBlock( + nil, + []*slack.TextBlockObject{ + slack.NewTextBlockObject("mrkdwn", ":cook: *Top restaurants*", false, false), + slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*%s (Total)*", dateOneMonthAgo.Month().String()), false, false), + }, + nil, + ) + + topVenuesRows := make([]*slack.TextBlockObject, 0, len(monthlyTopVenues)*2) + for i, venue := range monthlyTopVenues { + totalOrderCount, venueExists := venueIdToTotalOrderCount[venue.VenueId] + if !venueExists { + return nil, fmt.Errorf("venue %s (%s) is not in monthlyTopVenuesTotalCounts", venue.VenueId, venue.VenueName) + } + + positionEmoji, emojiExists := numberToEmojiMap[i+1] + if !emojiExists { + return nil, fmt.Errorf("unsupported ranking %d", i+1) + } + + venueHyperlink := fmt.Sprintf("<%s|%s>", venue.VenueLink, venue.VenueName) + leftColumnString := fmt.Sprintf("%s %s%s%s", positionEmoji, UnicodeLeftToRightMark, venueHyperlink, UnicodeLeftToRightMark) + if venue.OrderCount == totalOrderCount { + leftColumnString += " :new:" + } + + rightColumnString := fmt.Sprintf("%d (%d)", venue.OrderCount, totalOrderCount) + + topVenuesRows = append(topVenuesRows, + slack.NewTextBlockObject("mrkdwn", leftColumnString, false, false), + slack.NewTextBlockObject("mrkdwn", rightColumnString, false, false), + ) + } + + topVenuesBlocks := append( + []slack.Block{topVenuesHeader}, + slack.NewSectionBlock(nil, topVenuesRows, nil), + ) + + return topVenuesBlocks, nil +} + +func buildTopHostsMessageBlocks(monthlyTopHosts []order.MouthsFedCount, monthlyTopHostsTotalCounts []order.MouthsFedCount) ([]slack.Block, error) { + hostIdToTotalMouthsFedCount := make(map[string]int) + for _, host := range monthlyTopHostsTotalCounts { + hostIdToTotalMouthsFedCount[host.HostId] = host.MouthsFedCount + } + + topHostsHeader := slack.NewSectionBlock( + nil, + []*slack.TextBlockObject{ + slack.NewTextBlockObject("mrkdwn", ":spoon: *Most mouths fed*", false, false), + slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*%s (Total)*", dateOneMonthAgo.Month().String()), false, false), + }, + nil, + ) + + topHostsRows := make([]*slack.TextBlockObject, 0, len(monthlyTopHosts)*2) + for i, host := range monthlyTopHosts { + totalMouthsFedCount, hostExists := hostIdToTotalMouthsFedCount[host.HostId] + if !hostExists { + return nil, fmt.Errorf("host %s (%s) is not in monthlyTopHostsTotalCounts", host.HostId, host.HostName) + } + + positionEmoji, emojiExists := numberToEmojiMap[i+1] + if !emojiExists { + return nil, fmt.Errorf("unsupported ranking %d", i+1) + } + + leftColumnString := fmt.Sprintf("%s %s%s%s", positionEmoji, UnicodeLeftToRightMark, host.HostName, UnicodeLeftToRightMark) + if host.MouthsFedCount == totalMouthsFedCount { + leftColumnString += " :new:" + } + + rightColumnString := fmt.Sprintf("%d (%d)", host.MouthsFedCount, totalMouthsFedCount) + + topHostsRows = append(topHostsRows, + slack.NewTextBlockObject("mrkdwn", leftColumnString, false, false), + slack.NewTextBlockObject("mrkdwn", rightColumnString, false, false), + ) + } + + topHostsBlocks := append( + []slack.Block{topHostsHeader}, + slack.NewSectionBlock(nil, topHostsRows, nil), + ) + + return topHostsBlocks, nil +} + +func venueOrderCountsToVenueIds(venueOrderCounts []order.VenueOrderCount) []string { + var venueIds []string + for _, venueOrderCount := range venueOrderCounts { + venueIds = append(venueIds, venueOrderCount.VenueId) + } + return venueIds +} + +func mouthsFedCountsToHostIds(mouthsFedCounts []order.MouthsFedCount) []string { + var hostIds []string + for _, mouthsFedCount := range mouthsFedCounts { + hostIds = append(hostIds, mouthsFedCount.HostId) + } + return hostIds +} + +func (h *Service) getTopVenuesMessageBlocks(channelId string) ([]slack.Block, error) { + monthlyTopVenues, err := h.orderStore.GetVenuesWithMostOrders(dateOneMonthAgo, numberOfDigestRows, channelId, []string{}) + if err != nil { + return nil, fmt.Errorf("error getting top venues of the last month: %w", err) + } + + monthlyTopVenueIds := venueOrderCountsToVenueIds(monthlyTopVenues) + monthlyTopVenuesTotalCounts, err := h.orderStore.GetVenuesWithMostOrders(time.Time{}, numberOfDigestRows, channelId, monthlyTopVenueIds) + if err != nil { + return nil, fmt.Errorf("error getting top venues of all time: %w", err) + } + + return buildTopVenuesMessageBlocks(monthlyTopVenues, monthlyTopVenuesTotalCounts) +} + +func (h *Service) getTopHostsMessageBlocks(channelId string) ([]slack.Block, error) { + monthlyTopHosts, err := h.orderStore.GetHostsWithMostMouthsFed(dateOneMonthAgo, numberOfDigestRows, channelId, []string{}) + if err != nil { + return nil, fmt.Errorf("error getting top hosts of the last month: %w", err) + } + + monthlyTopHostIds := mouthsFedCountsToHostIds(monthlyTopHosts) + monthlyTopHostsTotalCounts, err := h.orderStore.GetHostsWithMostMouthsFed(time.Time{}, numberOfDigestRows, channelId, monthlyTopHostIds) + if err != nil { + return nil, fmt.Errorf("error getting top hosts of all time: %w", err) + } + + return buildTopHostsMessageBlocks(monthlyTopHosts, monthlyTopHostsTotalCounts) +} + +func (h *Service) sendMonthlyDigestForChannel(channelId string) { + log.Printf("Sending monthly digest for channel %s\n", channelId) + + titleHeader := slack.NewHeaderBlock( + &slack.TextBlockObject{ + Type: slack.PlainTextType, + Text: fmt.Sprintf("Welcome to Bolt's %s %d digest", dateOneMonthAgo.Month().String(), dateOneMonthAgo.Year()), + }, + ) + + topVenuesMessageBlocks, err := h.getTopVenuesMessageBlocks(channelId) + if err != nil { + log.Printf("Error getting top venues message blocks: %v", err) + return + } + + topHostsMessageBlocks, err := h.getTopHostsMessageBlocks(channelId) + if err != nil { + log.Printf("Error getting top hosts message blocks: %v", err) + return + } + + digestBlocks := slices.Concat( + []slack.Block{titleHeader}, + []slack.Block{slack.NewDividerBlock()}, + topVenuesMessageBlocks, + []slack.Block{slack.NewDividerBlock()}, + topHostsMessageBlocks, + ) + + _, err = h.eventNotification.SendBlocksMessage(channelId, digestBlocks, "") + if err != nil { + log.Printf("Error sending monthly digest message for for channel %s: %v", channelId, err) + return + } +} + +func (h *Service) SendMonthlyDigest() { + dateOneMonthAgo = time.Now().AddDate(0, -1, 0) + + activeChannelIds, err := h.orderStore.GetActiveChannelIds(dateOneMonthAgo) + if err != nil { + log.Printf("Error getting active channel IDs: %v", err) + return + } + + for _, channelId := range activeChannelIds { + h.sendMonthlyDigestForChannel(channelId) + } +} diff --git a/service/service.go b/service/service.go index 14ca76f..6cdf002 100644 --- a/service/service.go +++ b/service/service.go @@ -5,6 +5,8 @@ import ( "sync" "time" + "github.com/slack-go/slack" + "github.com/oriser/bolt/debt" "github.com/oriser/bolt/order" "github.com/oriser/bolt/user" @@ -12,6 +14,7 @@ import ( type EventNotification interface { SendMessage(receiver, event, messageID string) (string, error) + SendBlocksMessage(receiver string, blocks []slack.Block, messageID string) (string, error) EditMessage(receiver, event, messageID string) error AddReaction(receiver, messageID, reaction string) error } diff --git a/service/utils.go b/service/utils.go index 47f4a32..a9d9a51 100644 --- a/service/utils.go +++ b/service/utils.go @@ -2,6 +2,8 @@ package service import "time" +const UnicodeLeftToRightMark = "\u200E" + func IsUnixZero(t time.Time) bool { return t.Equal(time.Unix(0, 0)) } diff --git a/storage/db/orders.go b/storage/db/orders.go index 218b5e3..1dc2dcf 100644 --- a/storage/db/orders.go +++ b/storage/db/orders.go @@ -42,3 +42,86 @@ func (d *DBStore) SaveOrder(_ context.Context, order *order.Order) error { return nil } + +func (d *DBStore) GetVenuesWithMostOrders(startTime time.Time, limit uint64, channelId string, filteredVenueIds []string) ([]order.VenueOrderCount, error) { + query := sq.Select("venue_id", "venue_name", "venue_link", "COUNT(*) as order_count", "MAX(created_at) as last_created_at"). + From("orders"). + Where(sq.Eq{"receiver": channelId}). + Where(sq.Eq{"status": order.StatusDone}). + Where(sq.GtOrEq{"created_at": startTime}). + GroupBy("venue_id"). + OrderBy("order_count DESC", "last_created_at ASC") + if len(filteredVenueIds) > 0 { + query = query.Where(sq.Eq{"venue_id": filteredVenueIds}) + } + if limit > 0 { + query = query.Limit(limit) + } + + sql, args, err := query.ToSql() + if err != nil { + return nil, fmt.Errorf("building SELECT query: %w", err) + } + + var venueOrderCounts []order.VenueOrderCount + err = d.db.Select(&venueOrderCounts, sql, args...) + if err != nil { + return nil, fmt.Errorf("executing SELECT query: %w", err) + } + + return venueOrderCounts, nil +} + +func (d *DBStore) GetHostsWithMostMouthsFed(startTime time.Time, limit uint64, channelId string, filteredHostIds []string) ([]order.MouthsFedCount, error) { + subquery := sq.Select("orders.*", "json_each.value as participant"). + From("orders, json_each(orders.participants)"). + Where(sq.Gt{"json_extract(participant, '$.amount')": 0}). + Where("json_extract(participant, '$.name') != host"). + Where(sq.Eq{"receiver": channelId}). + Where(sq.Eq{"status": order.StatusDone}). + Where(sq.GtOrEq{"created_at": startTime}) + if len(filteredHostIds) > 0 { + subquery = subquery.Where(sq.Eq{"host_id": filteredHostIds}) + } + + query := sq.Select("host_id", "host", "COUNT(*) as mouths_fed_count", "MAX(created_at) as last_created_at"). + FromSelect(subquery, "extracted_participants"). + GroupBy("host_id"). + OrderBy("mouths_fed_count DESC", "last_created_at ASC") + if limit > 0 { + query = query.Limit(limit) + } + + sql, args, err := query.ToSql() + if err != nil { + return nil, fmt.Errorf("building SELECT query: %w", err) + } + + var mouthsFedCount []order.MouthsFedCount + err = d.db.Select(&mouthsFedCount, sql, args...) + if err != nil { + return nil, fmt.Errorf("executing SELECT query: %w", err) + } + + return mouthsFedCount, nil +} + +func (d *DBStore) GetActiveChannelIds(lastDateConsideredActive time.Time) ([]string, error) { + query := sq.Select("receiver"). + From("orders"). + Where(sq.GtOrEq{"created_at": lastDateConsideredActive}). + Distinct() + + sql, args, err := query.ToSql() + if err != nil { + return nil, fmt.Errorf("building SELECT query: %w", err) + } + + var activeChannelIds []string + err = d.db.Select(&activeChannelIds, sql, args...) + if err != nil { + return nil, fmt.Errorf("executing SELECT query: %w", err) + } + + return activeChannelIds, nil +}