Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
![Venue monitoring example](docs/assets/examples/monitor_venue.png)

### Bolt's monthly digest
![Monthly digest example](docs/assets/examples/monthly_digest.png)
12 changes: 12 additions & 0 deletions bot/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
19 changes: 19 additions & 0 deletions cmd/run/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
Binary file added docs/assets/examples/monthly_digest.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: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/oriser/bolt

go 1.18
go 1.22

require (
github.com/Jeffail/gabs/v2 v2.6.0
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
18 changes: 18 additions & 0 deletions order/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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)
}
215 changes: 215 additions & 0 deletions service/digest.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import (
"sync"
"time"

"github.com/slack-go/slack"

"github.com/oriser/bolt/debt"
"github.com/oriser/bolt/order"
"github.com/oriser/bolt/user"
)

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
}
Expand Down
2 changes: 2 additions & 0 deletions service/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package service

import "time"

const UnicodeLeftToRightMark = "\u200E"

func IsUnixZero(t time.Time) bool {
return t.Equal(time.Unix(0, 0))
}
Expand Down
Loading