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.
absences
William Perron 1 year ago
parent e9632dfaae
commit 638083a755
Signed by: wperron
GPG Key ID: BFDB4EF72D73C5F2

3
.gitignore vendored

@ -19,3 +19,6 @@ bin/
*.db *.db
*.db-shm *.db-shm
*.db-wal *.db-wal
# local env files
env.sh

@ -1,7 +1,7 @@
FROM golang:1.19-buster as builder FROM golang:1.21-bullseye as builder
WORKDIR /app WORKDIR /app
COPY . . 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 FROM ubuntu:22.04 as litestream
WORKDIR /download 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=litestream /download/litestream /usr/local/bin/litestream
COPY --from=builder /app/start.sh ./start.sh COPY --from=builder /app/start.sh ./start.sh
# install ca-certificates for outbound https calls, and sqlite3 for debugging # 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"] ENTRYPOINT ["./start.sh"]

@ -1 +1,110 @@
package themis 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)
}
}

@ -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))
}
}
}

@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"sort"
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
@ -52,6 +53,10 @@ func main() {
} }
defer store.Close() defer store.Close()
notifChan := make(chan struct{})
notifier := themis.NewNotifier(notifChan)
go notifier.Start(ctx)
authToken, ok := os.LookupEnv("DISCORD_TOKEN") authToken, ok := os.LookupEnv("DISCORD_TOKEN")
if !ok { if !ok {
log.Fatal().Err(err).Msg("no auth token found at DISCORD_TOKEN env var") 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") 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)) discord, err := discordgo.New(fmt.Sprintf("Bot %s", authToken))
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("failed to initialize discord session") 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{ handlers := map[string]Handler{
"info": func(s *discordgo.Session, i *discordgo.InteractionCreate) { "info": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
@ -437,6 +467,115 @@ func main() {
log.Error().Err(err).Msg("failed to respond to interaction") 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) registerHandlers(discord, handlers)
@ -465,6 +604,31 @@ func main() {
cancel() 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() <-ctx.Done()
log.Info().Msg("context cancelled, exiting") log.Info().Msg("context cancelled, exiting")

@ -2,12 +2,14 @@
app = "themis" app = "themis"
kill_signal = "SIGINT" kill_signal = "SIGINT"
kill_timeout = 5 kill_timeout = 30
processes = [] processes = []
[env] [env]
TZ = "America/New_York"
DISCORD_APP_ID = "1014881815921705030" DISCORD_APP_ID = "1014881815921705030"
DISCORD_GUILD_ID = "375417755777892353" DISCORD_GUILD_ID = "375417755777892353"
DISCORD_BOT_CHANNEL_ID = "1018997240968265768"
[experimental] [experimental]
allowed_public_ports = [] allowed_public_ports = []

@ -70,3 +70,27 @@ func FormatRows(rows *sql.Rows) (string, error) {
return sb.String(), nil 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()
}

@ -51,3 +51,44 @@ func TestFormatRowsInvalidQuery(t *testing.T) {
_, err = store.db.Query("SELECT count(name), distinct(trade_node) from provinces where region = 'France'") _, err = store.db.Query("SELECT count(name), distinct(trade_node) from provinces where region = 'France'")
assert.Error(t, err) 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)
}
})
}
}

@ -1,6 +1,6 @@
module go.wperron.io/themis module go.wperron.io/themis
go 1.19 go 1.21
require ( require (
github.com/bwmarrin/discordgo v0.26.1 github.com/bwmarrin/discordgo v0.26.1

@ -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 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 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 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 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 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= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=

@ -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
);

@ -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)
}
}

@ -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))
}

@ -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)
}
})
}
}
Loading…
Cancel
Save