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.
William Perron 1 year ago
parent 465231517e
commit 9aa181b474
Signed by: wperron
GPG Key ID: BFDB4EF72D73C5F2

@ -1,4 +1,4 @@
FROM golang:1.19-buster as builder
FROM golang:1.21-bullseye as builder
WORKDIR /app
COPY . .
RUN mkdir ./bin; go build -buildvcs=false -o ./bin ./cmd/...

@ -1,190 +1,170 @@
[![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/golang-migrate/migrate/ci.yaml?branch=master)](https://github.com/golang-migrate/migrate/actions/workflows/ci.yaml?query=branch%3Amaster)
[![GoDoc](https://pkg.go.dev/badge/github.com/golang-migrate/migrate)](https://pkg.go.dev/github.com/golang-migrate/migrate/v4)
[![Coverage Status](https://img.shields.io/coveralls/github/golang-migrate/migrate/master.svg)](https://coveralls.io/github/golang-migrate/migrate?branch=master)
[![packagecloud.io](https://img.shields.io/badge/deb-packagecloud.io-844fec.svg)](https://packagecloud.io/golang-migrate/migrate?filter=debs)
[![Docker Pulls](https://img.shields.io/docker/pulls/migrate/migrate.svg)](https://hub.docker.com/r/migrate/migrate/)
![Supported Go Versions](https://img.shields.io/badge/Go-1.19%2C%201.20-lightgrey.svg)
[![GitHub Release](https://img.shields.io/github/release/golang-migrate/migrate.svg)](https://github.com/golang-migrate/migrate/releases)
[![Go Report Card](https://goreportcard.com/badge/github.com/golang-migrate/migrate/v4)](https://goreportcard.com/report/github.com/golang-migrate/migrate/v4)
# migrate
__Database migrations written in Go. Use as [CLI](#cli-usage) or import as [library](#use-in-your-go-project).__
* Migrate reads migrations from [sources](#migration-sources)
and applies them in correct order to a [database](#databases).
* Drivers are "dumb", migrate glues everything together and makes sure the logic is bulletproof.
(Keeps the drivers lightweight, too.)
* Database drivers don't assume things or try to correct user input. When in doubt, fail.
Forked from [mattes/migrate](https://github.com/mattes/migrate)
## Databases
Database drivers run migrations. [Add a new database?](database/driver.go)
* [PostgreSQL](database/postgres)
* [PGX v4](database/pgx)
* [PGX v5](database/pgx/v5)
* [Redshift](database/redshift)
* [Ql](database/ql)
* [Cassandra](database/cassandra)
* [SQLite](database/sqlite)
* [SQLite3](database/sqlite3) ([todo #165](https://github.com/mattes/migrate/issues/165))
* [SQLCipher](database/sqlcipher)
* [MySQL/ MariaDB](database/mysql)
* [Neo4j](database/neo4j)
* [MongoDB](database/mongodb)
* [CrateDB](database/crate) ([todo #170](https://github.com/mattes/migrate/issues/170))
* [Shell](database/shell) ([todo #171](https://github.com/mattes/migrate/issues/171))
* [Google Cloud Spanner](database/spanner)
* [CockroachDB](database/cockroachdb)
* [YugabyteDB](database/yugabytedb)
* [ClickHouse](database/clickhouse)
* [Firebird](database/firebird)
* [MS SQL Server](database/sqlserver)
### Database URLs
Database connection strings are specified via URLs. The URL format is driver dependent but generally has the form: `dbdriver://username:password@host:port/dbname?param1=true&param2=false`
Any [reserved URL characters](https://en.wikipedia.org/wiki/Percent-encoding#Percent-encoding_reserved_characters) need to be escaped. Note, the `%` character also [needs to be escaped](https://en.wikipedia.org/wiki/Percent-encoding#Percent-encoding_the_percent_character)
Explicitly, the following characters need to be escaped:
`!`, `#`, `$`, `%`, `&`, `'`, `(`, `)`, `*`, `+`, `,`, `/`, `:`, `;`, `=`, `?`, `@`, `[`, `]`
It's easiest to always run the URL parts of your DB connection URL (e.g. username, password, etc) through an URL encoder. See the example Python snippets below:
# themis
Discord App to allow EU4 players to take claims on regions and provinces.
## Setup
### Requirements
To develop:
- [Go](https://go.dev/) version 1.19 or higher installed locally
- `sqlite3` installed locally (already ships by default on most OSes)
To deploy:
- Register for a [free account on Fly](https://fly.io)
- An [AWS account](https://console.aws.amazon.com)
### Steps
To work with the core modules that simply interact with the database, or make
sure that everything compiles correctly, you can run the following commands:
```bash
$ python3 -c 'import urllib.parse; print(urllib.parse.quote(input("String to encode: "), ""))'
String to encode: FAKEpassword!#$%&'()*+,/:;=?@[]
FAKEpassword%21%23%24%25%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D
$ python2 -c 'import urllib; print urllib.quote(raw_input("String to encode: "), "")'
String to encode: FAKEpassword!#$%&'()*+,/:;=?@[]
FAKEpassword%21%23%24%25%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D
$
go test ./...
go build -o ./bin/ ./cmd/...
```
## Migration Sources
## Operations
Source drivers read migrations from local or remote sources. [Add a new source?](source/driver.go)
This application is deployed via [Fly](https://fly.io) on a single virtual
machine instance. It uses an embed SQLite database as its database engine, and
uses [Litestream](https://litestream.io) to replicate the database to an S3
bucket.
* [Filesystem](source/file) - read from filesystem
* [io/fs](source/iofs) - read from a Go [io/fs](https://pkg.go.dev/io/fs#FS)
* [Go-Bindata](source/go_bindata) - read from embedded binary data ([jteeuwen/go-bindata](https://github.com/jteeuwen/go-bindata))
* [pkger](source/pkger) - read from embedded binary data ([markbates/pkger](https://github.com/markbates/pkger))
* [GitHub](source/github) - read from remote GitHub repositories
* [GitHub Enterprise](source/github_ee) - read from remote GitHub Enterprise repositories
* [Bitbucket](source/bitbucket) - read from remote Bitbucket repositories
* [Gitlab](source/gitlab) - read from remote Gitlab repositories
* [AWS S3](source/aws_s3) - read from Amazon Web Services S3
* [Google Cloud Storage](source/google_cloud_storage) - read from Google Cloud Platform Storage
### Application Configuration
## CLI usage
The deployment configurations can be found in the [fly.toml](/fly.toml) file and
you can find more information on the configuration options [in the official Fly
documentation](https://fly.io/docs/reference/configuration/).
* Simple wrapper around this library.
* Handles ctrl+c (SIGINT) gracefully.
* No config search paths, no config files, no magic ENV var injections.
The virtual image is based off of the [Dockerfile](/Dockerfile) which is a
multi-stage build that builds the main application binary, downloads Litestream
and packages everything on top of an Ubuntu 22.04 base.
__[CLI Documentation](cmd/migrate)__
### Entrypoint
### Basic usage
The application is started using a [custom entrypoint shell script](/start.sh)
that is in charge of first restoring the database file through Litestream and
then starting the main application as a [child process of Litestream's
replication process](https://litestream.io/reference/replicate/#arguments).
```bash
$ migrate -source file://path/to/migrations -database postgres://localhost:5432/database up 2
```
It's a very simple script but is necessary since the application doesn't have a
persistent volume to rely on and must rehydrate its database file after every
deployment.
### Docker usage
### Environment Variables
```bash
$ docker run -v {{ migration dir }}:/migrations --network host migrate/migrate
-path=/migrations/ -database postgres://localhost:5432/database up 2
```
| Env Var | Defined At |
| ----------------------- | --------------------- |
| `DISCORD_TOKEN` | fly secret |
| `DISCORD_APP_ID` | [fly.toml](/fly.toml) |
| `DISCORD_GUILD_ID` | [fly.toml](fly.toml) |
| `AWS_ACCESS_KEY_ID` | fly secret |
| `AWS_SECRET_ACCESS_KEY` | fly secret |
## Use in your Go project
* API is stable and frozen for this release (v3 & v4).
* Uses [Go modules](https://golang.org/cmd/go/#hdr-Modules__module_versions__and_more) to manage dependencies.
* To help prevent database corruptions, it supports graceful stops via `GracefulStop chan bool`.
* Bring your own logger.
* Uses `io.Reader` streams internally for low memory overhead.
* Thread-safe and no goroutine leaks.
__[Go Documentation](https://pkg.go.dev/github.com/golang-migrate/migrate/v4)__
```go
import (
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/github"
)
func main() {
m, err := migrate.New(
"github://mattes:personal-access-token@mattes/migrate_test",
"postgres://localhost:5432/database?sslmode=enable")
m.Steps(2)
}
```
## Local Development
Want to use an existing database client?
```go
import (
"database/sql"
_ "github.com/lib/pq"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
func main() {
db, err := sql.Open("postgres", "postgres://localhost:5432/database?sslmode=enable")
driver, err := postgres.WithInstance(db, &postgres.Config{})
m, err := migrate.NewWithDatabaseInstance(
"file:///migrations",
"postgres", driver)
m.Up() // or m.Step(2) if you want to explicitly set the number of migrations to run
}
```
### Application Entrypoint
## Getting started
- `./cmd/themis-server` This is the main application entrypoint
- `./cmd/themis-repl` _coming soon_
Go to [getting started](GETTING_STARTED.md)
### Core Functions
## Tutorials
The core database functions can be developed and tested locally easily, just run
`go test ./...` to test your changes locally.
* [CockroachDB](database/cockroachdb/TUTORIAL.md)
* [PostgreSQL](database/postgres/TUTORIAL.md)
You can also load a test database easily by using the `init.sql` script. You can
use the cli sqlite3 client or connect using whatever SQL editor you prefer that
can open sqlite3 connections.
(more tutorials to come)
```bash
sqlite3 local.db < migrations/init.sql
sqlite3 local.db
# interactive SQLite session
```
## Migration files
### Discord Integration
Each migration has an up and down migration. [Why?](FAQ.md#why-two-separate-files-up-and-down-for-a-migration)
This is a work in progress, but I am currently using a dedicated Discord server
with this application already signed into it. You can contact me directly to be
invited into that server. From there you only need to set the following
environment variables before launching the `./cmd/themis-server` entrypoint:
```bash
1481574547_create_users_table.up.sql
1481574547_create_users_table.down.sql
export DISCORD_APP_ID="1014881815921705030"
export DISCORD_GUILD_ID="[test server id goes here]"
```
[Best practices: How to write migrations.](MIGRATIONS.md)
### Litestream replication
Litestream is _not a necessary component_ to run the `./cmd/themis-server`
entrypoint and can be safely ignored when developing locally. Still, if you wish
to try it out, you can find the Litestream commands used in production in the
[start.sh](/start.sh) script. As per the Litestream docs, it should work fine
with [Minio](https://min.io/) but I have not tested it yet nor are there any
scripts provided to run it (yet).
## SQLite
### Importing From CSV
This is a neat feature built-in to SQLite. Using the source file at
[data/eu4-provinces.csv](/data/eu4-provinces.csv) you can import the data
directly into a SQLite database using the following command:
## Versions
```bash
$ sqlite3
# ...
sqlite> .mode csv
sqlite> .import data/eu4-provinces.csv provinces
sqlite> .schema provinces
CREATE TABLE provinces(
"ID" TEXT,
"Name" TEXT,
"Development" TEXT,
"BT" TEXT,
"BP" TEXT,
"BM" TEXT,
"Trade good" TEXT,
"Trade node" TEXT,
"Modifiers" TEXT,
"Type" TEXT,
"Continent" TEXT,
"Superregion" TEXT,
"Region" TEXT,
"Area" TEXT
);
sqlite> select count(1) from provinces;
3925
```
### Creating an SQL Dump From Imported Data
Version | Supported? | Import | Notes
--------|------------|--------|------
**master** | :white_check_mark: | `import "github.com/golang-migrate/migrate/v4"` | New features and bug fixes arrive here first |
**v4** | :white_check_mark: | `import "github.com/golang-migrate/migrate/v4"` | Used for stable releases |
**v3** | :x: | `import "github.com/golang-migrate/migrate"` (with package manager) or `import "gopkg.in/golang-migrate/migrate.v3"` (not recommended) | **DO NOT USE** - No longer supported |
The init script at [migrations/init.sql](/migrations/init.sql) was initially
created after importing the CSV data and running the following commands:
## Development and Contributing
```bash
# In the same SQlite session as last section
sqlite> .output dump.sql
sqlite> .dump
```
Yes, please! [`Makefile`](Makefile) is your friend,
read the [development guide](CONTRIBUTING.md).
Note: The column names were _edited manually_ to remove capital letters and
spaces to make it easier to work with.
Also have a look at the [FAQ](FAQ.md).
### Claims Schema
---
```sql
CREATE TABLE claim_types (
claim_type TEXT PRIMARY KEY
);
Looking for alternatives? [https://awesome-go.com/#database](https://awesome-go.com/#database).
CREATE TABLE claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player TEXT,
claim_type TEXT,
val TEXT,
FOREIGN KEY(claim_type) REFERENCES claim_types(claim_type)
);
```

@ -22,7 +22,7 @@ func (s *Store) AddAbsence(ctx context.Context, session time.Time, userId string
return fmt.Errorf("failed to prepare absence query: %w", err)
}
_, err = stmt.ExecContext(ctx, session.Format("2006-01-02"), userId)
_, err = stmt.ExecContext(ctx, session.Format(time.DateOnly), userId)
if err != nil {
return fmt.Errorf("failed to insert absence: %w", err)
}
@ -42,7 +42,7 @@ func (s *Store) GetAbsentees(ctx context.Context, session time.Time) ([]string,
return nil, fmt.Errorf("failed to prepare query: %w", err)
}
rows, err := stmt.QueryContext(ctx, session.Format("2006-01-02"))
rows, err := stmt.QueryContext(ctx, session.Format(time.DateOnly))
if err != nil {
return nil, fmt.Errorf("failed to execute query: %w", err)
}
@ -61,12 +61,13 @@ func (s *Store) GetAbsentees(ctx context.Context, session time.Time) ([]string,
return absentees, nil
}
type Foo struct {
session_date string
userid string
}
// 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)
func (s *Store) GetSchedule(ctx context.Context, from, to time.Time) ([]Foo, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, fmt.Errorf("failed to begin transaction: %w", err)
@ -78,21 +79,32 @@ func (s *Store) GetSchedule(ctx context.Context, from, to time.Time) ([]Foo, err
return nil, fmt.Errorf("failed to prepare query: %w", err)
}
rows, err := stmt.QueryContext(ctx, from.Format("2006-01-02"), to.Format("2006-01-02"))
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)
}
schedule := make([]Foo, 0)
for rows.Next() {
var foo Foo
err = rows.Scan(&foo.session_date, &foo.userid)
var date string
var user string
err = rows.Scan(&date, &user)
if err != nil {
return nil, fmt.Errorf("failed to scan row: %w", err)
}
schedule = append(schedule, foo)
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)
}
}

@ -35,7 +35,15 @@ func TestGetSchedule(t *testing.T) {
_ = store.AddAbsence(context.TODO(), now.Add(7*24*time.Hour), "foobar")
schedule, err := store.GetSchedule(context.TODO(), now, now.Add(2*7*24*time.Hour))
schedule, err := store.GetSchedule(context.TODO(), now, now.AddDate(0, 0, 14))
assert.NoError(t, err)
assert.Equal(t, 1, len(schedule))
// 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"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"syscall"
@ -152,6 +153,11 @@ func main() {
},
},
},
{
Name: "schedule",
Description: "Get the schedule for the following weeks.",
Type: discordgo.ChatApplicationCommand,
},
}
handlers := map[string]Handler{
"info": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
@ -438,7 +444,44 @@ func main() {
}
},
"schedule": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
store.GetSchedule(ctx, time.Now(), time.Now().Add(4*7*24*time.Hour))
// 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 := range sched {
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(strings.Join(sched[d], ", ") + " won't be able to make it")
}
}
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")
}
},
}

@ -2,7 +2,7 @@
app = "themis"
kill_signal = "SIGINT"
kill_timeout = 5
kill_timeout = 30
processes = []
[env]

@ -1,6 +1,6 @@
module go.wperron.io/themis
go 1.19
go 1.21
require (
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/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=

Loading…
Cancel
Save