From 638083a755e8e2c1ee5ee5a5fcef4ee7e753b70a Mon Sep 17 00:00:00 2001 From: William Perron Date: Mon, 13 Nov 2023 21:00:05 -0500 Subject: [PATCH] Add absences feature set ## Add absences table and basic queries * Add migration scripts * Add queries to add, get and list absences * Add tests for the above * Add function to get the date of the next Monday Also fixes potential bug in the build where the output binary would be written to a file called `./bin` rather than a file named after the source directory in a _directory_ called `./bin`. ## Add /schedule command Adds the Discord slash command to get the schedule for the next few weeks. Also updates the required Go version to 1.21 to benefit from the new `time.DateOnly` format that's gonna be used in the absences table. ## Add scheduled message every Saturday 5pm Adds the `notify.go` file which manages the scheduling of the recurrent message that will ping the group every week about the following game. ## Add /absent command Adds command to register as absent for a specific date. The command takes in one optional parameter for the date, if none is specified, defaults to the next session. --- .gitignore | 3 + Dockerfile | 7 +- absences.go | 109 ++++++++++++++ absences_test.go | 49 +++++++ cmd/themis-server/main.go | 164 ++++++++++++++++++++++ fly.toml | 4 +- fmt.go | 24 ++++ fmt_test.go | 41 ++++++ go.mod | 2 +- go.sum | 1 + migrations/20231113_add_absences.down.sql | 1 + migrations/20231113_add_absences.up.sql | 5 + notify.go | 68 +++++++++ time.go | 13 ++ time_test.go | 46 ++++++ 15 files changed, 532 insertions(+), 5 deletions(-) create mode 100644 absences_test.go create mode 100644 migrations/20231113_add_absences.down.sql create mode 100644 migrations/20231113_add_absences.up.sql create mode 100644 notify.go create mode 100644 time.go create mode 100644 time_test.go diff --git a/.gitignore b/.gitignore index ee4d410..6647a79 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ bin/ *.db *.db-shm *.db-wal + +# local env files +env.sh diff --git a/Dockerfile b/Dockerfile index e5e29d2..488bb44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -FROM golang:1.19-buster as builder +FROM golang:1.21-bullseye as builder WORKDIR /app COPY . . -RUN go build -buildvcs=false -o ./bin ./cmd/... +RUN mkdir ./bin; go build -buildvcs=false -o ./bin ./cmd/... FROM ubuntu:22.04 as litestream WORKDIR /download @@ -15,5 +15,6 @@ COPY --from=builder /app/bin/themis-server /usr/local/bin/themis-server COPY --from=litestream /download/litestream /usr/local/bin/litestream COPY --from=builder /app/start.sh ./start.sh # install ca-certificates for outbound https calls, and sqlite3 for debugging -RUN apt update -y; apt install -y ca-certificates sqlite3; apt-get clean +# tzdata is needed to resolve the timezone on startup +RUN apt update -y; apt install -y ca-certificates sqlite3 tzdata; apt-get clean ENTRYPOINT ["./start.sh"] diff --git a/absences.go b/absences.go index 93c5f9c..d5e4164 100644 --- a/absences.go +++ b/absences.go @@ -1 +1,110 @@ package themis + +import ( + "context" + "fmt" + "time" +) + +func (s *Store) AddAbsence(ctx context.Context, session time.Time, userId string) error { + if session.Weekday() != time.Monday { + return fmt.Errorf("not a monday") + } + + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Commit() //nolint:errcheck + + stmt, err := s.db.PrepareContext(ctx, "INSERT INTO absences (session_date, userid) VALUES (?, ?)") + if err != nil { + return fmt.Errorf("failed to prepare absence query: %w", err) + } + + _, err = stmt.ExecContext(ctx, session.Format(time.DateOnly), userId) + if err != nil { + return fmt.Errorf("failed to insert absence: %w", err) + } + + return nil +} + +func (s *Store) GetAbsentees(ctx context.Context, session time.Time) ([]string, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Commit() //nolint:errcheck + + stmt, err := s.db.PrepareContext(ctx, `SELECT userid FROM absences WHERE session_date = ?`) + if err != nil { + return nil, fmt.Errorf("failed to prepare query: %w", err) + } + + rows, err := stmt.QueryContext(ctx, session.Format(time.DateOnly)) + if err != nil { + return nil, fmt.Errorf("failed to execute query: %w", err) + } + + absentees := make([]string, 0) + for rows.Next() { + var abs string + err = rows.Scan(&abs) + if err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + + absentees = append(absentees, abs) + } + + return absentees, nil +} + +// map session_date -> list of absentees +type Schedule map[string][]string + +func (s *Store) GetSchedule(ctx context.Context, from, to time.Time) (Schedule, error) { + schedule := make(Schedule) + initSchedule(schedule, from, to) + + tx, err := s.db.Begin() + if err != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Commit() //nolint:errcheck + + stmt, err := s.db.PrepareContext(ctx, `SELECT session_date, userid FROM absences WHERE session_date BETWEEN ? AND ? ORDER BY session_date ASC`) + if err != nil { + return nil, fmt.Errorf("failed to prepare query: %w", err) + } + + rows, err := stmt.QueryContext(ctx, from.Format(time.DateOnly), to.Format(time.DateOnly)) + if err != nil { + return nil, fmt.Errorf("failed to execute query: %w", err) + } + + for rows.Next() { + var date string + var user string + err = rows.Scan(&date, &user) + if err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + + if _, ok := schedule[date]; ok { + schedule[date] = append(schedule[date], user) + } else { + schedule[date] = []string{user} + } + } + + return schedule, nil +} + +func initSchedule(schedule Schedule, from, to time.Time) { + for from.Before(to) || from.Equal(to) { + schedule[from.Format(time.DateOnly)] = []string{} + from = from.AddDate(0, 0, 7) + } +} diff --git a/absences_test.go b/absences_test.go new file mode 100644 index 0000000..710e4fe --- /dev/null +++ b/absences_test.go @@ -0,0 +1,49 @@ +package themis + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddAbsence(t *testing.T) { + store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestAddAbsence")) + require.NoError(t, err) + + now := NextMonday() + assert.NoError(t, store.AddAbsence(context.TODO(), now, "foobarbaz")) + absentees, err := store.GetAbsentees(context.TODO(), now) + assert.NoError(t, err) + assert.Equal(t, 1, len(absentees)) + assert.Equal(t, "foobarbaz", absentees[0]) + + assert.NoError(t, store.AddAbsence(context.TODO(), now, "foobarbaz")) + absentees, err = store.GetAbsentees(context.TODO(), now) + assert.NoError(t, err) + assert.Equal(t, 1, len(absentees)) +} + +func TestGetSchedule(t *testing.T) { + store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestGetSchedule")) + require.NoError(t, err) + + now := NextMonday() + + _ = store.AddAbsence(context.TODO(), now.Add(7*24*time.Hour), "foobar") + + schedule, err := store.GetSchedule(context.TODO(), now, now.AddDate(0, 0, 14)) + assert.NoError(t, err) + // reason being, the schedule should initialize to the desired time range + assert.Equal(t, 3, len(schedule)) + for d, a := range schedule { + if d == now.Add(7*24*time.Hour).Format(time.DateOnly) { + assert.Equal(t, 1, len(a)) + } else { + assert.Equal(t, 0, len(a)) + } + } +} diff --git a/cmd/themis-server/main.go b/cmd/themis-server/main.go index 9813887..490d310 100644 --- a/cmd/themis-server/main.go +++ b/cmd/themis-server/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "os/signal" + "sort" "strconv" "strings" "syscall" @@ -52,6 +53,10 @@ func main() { } defer store.Close() + notifChan := make(chan struct{}) + notifier := themis.NewNotifier(notifChan) + go notifier.Start(ctx) + authToken, ok := os.LookupEnv("DISCORD_TOKEN") if !ok { log.Fatal().Err(err).Msg("no auth token found at DISCORD_TOKEN env var") @@ -67,6 +72,11 @@ func main() { log.Fatal().Err(err).Msg("no guild id found at DISCORD_GUILD_ID env var") } + channelId, ok := os.LookupEnv("DISCORD_BOT_CHANNEL_ID") + if !ok { + log.Fatal().Err(err).Msg("no channel id found at DISCORD_BOT_CHANNEL_ID env var") + } + discord, err := discordgo.New(fmt.Sprintf("Bot %s", authToken)) if err != nil { log.Fatal().Err(err).Msg("failed to initialize discord session") @@ -152,6 +162,26 @@ func main() { }, }, }, + + // Scheduling commands + { + Name: "schedule", + Description: "Get the schedule for the following weeks.", + Type: discordgo.ChatApplicationCommand, + }, + { + Name: "absent", + Description: "Mark yourself as absent for a session", + Type: discordgo.ChatApplicationCommand, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "date", + Required: false, + Description: "Date of the session you can't make it to. YYYY-MM-DD format.", + Type: discordgo.ApplicationCommandOptionString, + }, + }, + }, } handlers := map[string]Handler{ "info": func(s *discordgo.Session, i *discordgo.InteractionCreate) { @@ -437,6 +467,115 @@ func main() { log.Error().Err(err).Msg("failed to respond to interaction") } }, + "schedule": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + // get schedule from now to 4 mondays into the future + sched, err := store.GetSchedule(ctx, themis.NextMonday(), themis.NextMonday().Add(4*7*24*time.Hour)) + if err != nil { + log.Error().Err(err).Msg("failed to get schedule") + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "failed to get schedule, check logs for more info.", + }, + }); err != nil { + log.Error().Err(err).Msg("failed to respond to interaction") + } + } + + sb := strings.Builder{} + keys := make([]string, 0, len(sched)) + for k, abs := range sched { + for i, a := range abs { + a = fmt.Sprintf("<@%s>", a) + abs[i] = a + } + keys = append(keys, k) + } + sort.Strings(keys) + + for _, d := range keys { + sb.WriteString(d + ": ") + if len(sched[d]) == 0 { + sb.WriteString("Everyone is available!\n") + } else { + sb.WriteString(themis.FormatStringSlice(sched[d]) + " won't be able to make it\n") + } + } + + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: sb.String(), + }, + }); err != nil { + log.Error().Err(err).Msg("failed to respond to interaction") + } + }, + "absent": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + var rawDate string + if len(i.ApplicationCommandData().Options) == 0 { + rawDate = themis.NextMonday().Format(time.DateOnly) + } else { + rawDate = i.ApplicationCommandData().Options[0].StringValue() + } + + date, err := time.Parse(time.DateOnly, rawDate) + if err != nil { + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "failed to parse provided date, make sure to use the YYYY-MM-DD format.", + }, + }); err != nil { + log.Error().Err(err).Msg("failed to respond to interaction") + } + } + + if date.Before(time.Now()) { + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "The date must be some time in the future.", + }, + }); err != nil { + log.Error().Err(err).Msg("failed to respond to interaction") + } + } + + if date.Weekday() != time.Monday { + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "The date you provided is not a Monday.", + }, + }); err != nil { + log.Error().Err(err).Msg("failed to respond to interaction") + } + // TODO(wperron) suggest Mondays before and after? + } + + userId := i.Member.User.ID + if err := store.AddAbsence(ctx, date, userId); err != nil { + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "something went wrong recording your absence, check logs for more info.", + }, + }); err != nil { + log.Error().Err(err).Msg("failed to respond to interaction") + } + } + + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Okey dokey.", + }, + }) + if err != nil { + log.Error().Err(err).Msg("failed to respond to interaction") + } + }, } registerHandlers(discord, handlers) @@ -465,6 +604,31 @@ func main() { cancel() }() + go notifier.NotifyFunc(ctx, func() { + absentees, err := store.GetAbsentees(ctx, themis.NextMonday()) + if err != nil { + log.Error().Err(err).Msg("failed to get absentees for next session") + return + } + + for i, a := range absentees { + a = fmt.Sprintf("<@%s>", a) + absentees[i] = a + } + + var msg string + if len(absentees) == 0 { + msg = "Everybody can make it next Monday, see you then!" + } else { + msg = fmt.Sprintf("%s can't make it next Monday. :sad:", themis.FormatStringSlice(absentees)) + } + + _, err = discord.ChannelMessageSend(channelId, msg) + if err != nil { + log.Error().Err(err).Msg("failed to send scheduled notification") + } + }) + <-ctx.Done() log.Info().Msg("context cancelled, exiting") diff --git a/fly.toml b/fly.toml index cc2961b..921e62a 100644 --- a/fly.toml +++ b/fly.toml @@ -2,12 +2,14 @@ app = "themis" kill_signal = "SIGINT" -kill_timeout = 5 +kill_timeout = 30 processes = [] [env] +TZ = "America/New_York" DISCORD_APP_ID = "1014881815921705030" DISCORD_GUILD_ID = "375417755777892353" +DISCORD_BOT_CHANNEL_ID = "1018997240968265768" [experimental] allowed_public_ports = [] diff --git a/fmt.go b/fmt.go index af4acdc..fa090e8 100644 --- a/fmt.go +++ b/fmt.go @@ -70,3 +70,27 @@ func FormatRows(rows *sql.Rows) (string, error) { return sb.String(), nil } + +func FormatStringSlice(s []string) string { + if len(s) == 0 { + return "" + } + + sb := strings.Builder{} + for len(s) > 0 { + curr, rest := s[0], s[1:] + sb.WriteString(curr) + + if len(rest) == 0 { + break + } + + if len(rest) > 1 { + sb.WriteString(", ") + } else { + sb.WriteString(" and ") + } + s = rest + } + return sb.String() +} diff --git a/fmt_test.go b/fmt_test.go index 0529da4..3af3cd2 100644 --- a/fmt_test.go +++ b/fmt_test.go @@ -51,3 +51,44 @@ func TestFormatRowsInvalidQuery(t *testing.T) { _, err = store.db.Query("SELECT count(name), distinct(trade_node) from provinces where region = 'France'") assert.Error(t, err) } + +func TestFormatStringSlice(t *testing.T) { + tests := []struct { + name string + s []string + want string + }{ + { + name: "empty", + s: []string{}, + want: "", + }, + { + name: "single", + s: []string{"foo"}, + want: "foo", + }, + { + name: "two", + s: []string{"foo", "bar"}, + want: "foo and bar", + }, + { + name: "three", + s: []string{"foo", "bar", "baz"}, + want: "foo, bar and baz", + }, + { + name: "four", + s: []string{"foo", "bar", "baz", "biz"}, + want: "foo, bar, baz and biz", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FormatStringSlice(tt.s); got != tt.want { + t.Errorf("FormatStringSlice() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go.mod b/go.mod index 7c0f157..8e5e82f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module go.wperron.io/themis -go 1.19 +go 1.21 require ( github.com/bwmarrin/discordgo v0.26.1 diff --git a/go.sum b/go.sum index 2de4c7e..9eb4eca 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,7 @@ github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= diff --git a/migrations/20231113_add_absences.down.sql b/migrations/20231113_add_absences.down.sql new file mode 100644 index 0000000..0e2514a --- /dev/null +++ b/migrations/20231113_add_absences.down.sql @@ -0,0 +1 @@ +DROP TABLE absences; diff --git a/migrations/20231113_add_absences.up.sql b/migrations/20231113_add_absences.up.sql new file mode 100644 index 0000000..7efe8c5 --- /dev/null +++ b/migrations/20231113_add_absences.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS absences ( + session_date TEXT, -- 2006-11-23 + userid TEXT, + UNIQUE(session_date, userid) ON CONFLICT IGNORE +); diff --git a/notify.go b/notify.go new file mode 100644 index 0000000..f0eb230 --- /dev/null +++ b/notify.go @@ -0,0 +1,68 @@ +package themis + +import ( + "context" + "fmt" + "time" +) + +var loc *time.Location + +func init() { + loc, _ = time.LoadLocation("America/New_York") +} + +type Notifier struct { + c chan struct{} +} + +func NewNotifier(c chan struct{}) *Notifier { + return &Notifier{ + c: c, + } +} + +func (n *Notifier) Start(ctx context.Context) { + m := NextMonday() + sat := m.AddDate(0, 0, -2) + if sat.Before(time.Now()) { + sat = sat.AddDate(0, 0, 7) + } + + t, err := time.ParseInLocation(time.DateTime, fmt.Sprintf("%s 17:00:00", sat.Format(time.DateOnly)), loc) + if err != nil { + panic("failed to parse next monday notif time. this is likely a bug.") + } + + first := time.NewTimer(time.Until(t)) + <-first.C + select { + case <-ctx.Done(): + return + default: + n.c <- struct{}{} + } + + ticker := time.NewTicker(time.Hour * 24 * 7) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + n.c <- struct{}{} + } + time.Sleep(time.Second) + } +} + +func (n *Notifier) NotifyFunc(ctx context.Context, f func()) { + for { + select { + case <-ctx.Done(): + return + case <-n.c: + f() + } + time.Sleep(time.Second) + } +} diff --git a/time.go b/time.go new file mode 100644 index 0000000..22b1eaa --- /dev/null +++ b/time.go @@ -0,0 +1,13 @@ +package themis + +import "time" + +var now func() time.Time + +func init() { + now = time.Now +} + +func NextMonday() time.Time { + return now().AddDate(0, 0, int((8-now().Weekday())%7)) +} diff --git a/time_test.go b/time_test.go new file mode 100644 index 0000000..f2d71a7 --- /dev/null +++ b/time_test.go @@ -0,0 +1,46 @@ +package themis + +import ( + "reflect" + "testing" + "time" +) + +func TestNextMonday(t *testing.T) { + tests := []struct { + name string + seed string + want string + }{ + { + name: "on monday", + seed: "2023-11-13T15:04:05Z07:00", + want: "2023-11-13T15:04:05Z07:00", + }, + { + name: "on sunday", + seed: "2023-11-12T15:04:05Z07:00", + want: "2023-11-13T15:04:05Z07:00", + }, + { + name: "on tuesday", + seed: "2023-11-14T15:04:05Z07:00", + want: "2023-11-20T15:04:05Z07:00", + }, + { + name: "on saturday", + seed: "2023-11-18T15:04:05Z07:00", + want: "2023-11-20T15:04:05Z07:00", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + seedt, _ := time.Parse(time.RFC3339, tt.seed) + now = func() time.Time { return seedt } + wantt, _ := time.Parse(time.RFC3339, tt.want) + if got := NextMonday(); !reflect.DeepEqual(got, wantt) { + t.Errorf("NextMonday() = %v, want %v", got, wantt) + } + }) + } +}