Skip to content

Commit 43c0a2d

Browse files
committed
initial commit
0 parents  commit 43c0a2d

13 files changed

+524
-0
lines changed

.env.example

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
SLACK_TOKEN=xxxx-YOUR-SLACK-OAUTH-TOKEN
2+
3+
# Comma separated list of channel ids
4+
SLACK_CHANNEL_IDS=ABC,XXX,YYY,ZZZ
5+
6+
SLACK_BOT_ID=ABC123XYZ
7+
8+
RUN_INTERVAL_DAYS=14

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*.db
2+
*.sqlite
3+
.env

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# bagel-bot
2+
Donut app for Slack clone

cmd/run/main.go

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"math/rand"
6+
"time"
7+
8+
"github.com/jordi-reinsma/bagel/core"
9+
"github.com/jordi-reinsma/bagel/db"
10+
"github.com/jordi-reinsma/bagel/slack"
11+
)
12+
13+
func main() {
14+
fmt.Println("Starting bagel...")
15+
rand.Seed(time.Now().UnixNano())
16+
17+
// change to true to reset the database
18+
DB := db.MustConnect(false)
19+
defer DB.Close()
20+
21+
skip, err := core.ShouldSkipExecution(DB)
22+
if err != nil {
23+
panic(err)
24+
}
25+
if skip {
26+
fmt.Println("Skipping execution")
27+
return
28+
}
29+
30+
// change to true to use mock data
31+
slack := slack.New(false)
32+
33+
channelIDs := slack.GetChannelUUIDs()
34+
35+
for _, channelID := range channelIDs {
36+
fmt.Println("Generating matches for channel", channelID)
37+
38+
userUUIDs, err := slack.GetUserUUIDs(channelID)
39+
if err != nil {
40+
panic(err)
41+
}
42+
43+
users, err := DB.AddAndGetUsers(userUUIDs)
44+
if err != nil {
45+
panic(err)
46+
}
47+
48+
fmt.Println("Users:", len(users))
49+
50+
matches, err := core.GenerateMatches(DB, users)
51+
if err != nil {
52+
panic(err)
53+
}
54+
55+
for _, match := range matches {
56+
err = slack.SendMessage(match, channelID)
57+
58+
if err != nil {
59+
fmt.Println(match, err)
60+
continue
61+
}
62+
err = DB.UpdateMatch(match)
63+
if err != nil {
64+
fmt.Println(match, err)
65+
continue
66+
}
67+
}
68+
69+
err = slack.SendChannelMessage(channelID)
70+
if err != nil {
71+
panic(err)
72+
}
73+
74+
fmt.Println("Matches:", len(matches))
75+
}
76+
77+
err = DB.SaveExecution()
78+
if err != nil {
79+
panic(err)
80+
}
81+
82+
fmt.Println("Done!")
83+
}

core/matcher.go

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package core
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"math/rand"
7+
"sort"
8+
9+
"github.com/jordi-reinsma/bagel/db"
10+
"github.com/jordi-reinsma/bagel/model"
11+
)
12+
13+
func GenerateMatches(DB db.DB, users []model.User) ([]model.Match, error) {
14+
pairs, err := preparePairs(DB, users)
15+
if err != nil {
16+
return nil, err
17+
}
18+
19+
return greedyMatcher(users, pairs)
20+
}
21+
22+
func preparePairs(DB db.DB, users []model.User) ([]model.Match, error) {
23+
var pairs []model.Match
24+
for i := 0; i < len(users)-1; i++ {
25+
for j := i + 1; j < len(users); j++ {
26+
if users[i].ID == users[j].ID {
27+
continue
28+
}
29+
pairs = append(pairs, model.Match{A: users[i], B: users[j]})
30+
}
31+
}
32+
return DB.AddAndGetPairs(pairs)
33+
}
34+
35+
// greedyMatcher implements the greedy algorithm for the minimum weight perfect matching problem
36+
func greedyMatcher(users []model.User, pairs []model.Match) ([]model.Match, error) {
37+
// shuffle the pairs to remove bias towards lower ids
38+
swapPair := func(i, j int) {
39+
pairs[i], pairs[j] = pairs[j], pairs[i]
40+
}
41+
rand.Shuffle(len(pairs), swapPair)
42+
43+
// stable sort the pairs by the least matches they have
44+
byFrequency := func(i, j int) bool {
45+
return pairs[i].Freq < pairs[j].Freq
46+
}
47+
sort.SliceStable(pairs, byFrequency)
48+
49+
// number of matches is the ceil of the half of the number of users
50+
numMatches := len(users)/2 + len(users)%2
51+
52+
matches := make([]model.Match, 0, numMatches)
53+
matched := make(map[int]bool)
54+
for _, user := range users {
55+
matched[user.ID] = false
56+
}
57+
58+
// iterate over the pairs and match unmatched users
59+
for _, pair := range pairs {
60+
// break the loop if we have enough matches
61+
if len(matches) == numMatches-len(users)%2 {
62+
break
63+
}
64+
ok1 := matched[pair.A.ID]
65+
ok2 := matched[pair.B.ID]
66+
if ok1 || ok2 {
67+
continue
68+
}
69+
matches = append(matches, pair)
70+
matched[pair.A.ID] = true
71+
matched[pair.B.ID] = true
72+
}
73+
74+
// in the case of an odd number of users, we need to add the last unmatched user
75+
if len(matches) == numMatches-1 {
76+
var unmatched int
77+
for id, found := range matched {
78+
if !found {
79+
unmatched = id
80+
break
81+
}
82+
}
83+
if unmatched == 0 {
84+
return nil, errors.New("no unmatched user found but one match is missing")
85+
}
86+
// match the unmatched user to the first available pair
87+
for _, pair := range pairs {
88+
if pair.A.ID == unmatched || pair.B.ID == unmatched {
89+
matches = append(matches, pair)
90+
break
91+
}
92+
}
93+
}
94+
95+
if len(matches) != numMatches {
96+
return nil, fmt.Errorf("could not generate %d matches", numMatches)
97+
}
98+
99+
return matches, nil
100+
}

core/skipper.go

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package core
2+
3+
import (
4+
"os"
5+
"strconv"
6+
"time"
7+
8+
"github.com/jordi-reinsma/bagel/db"
9+
)
10+
11+
var runIntervalDays = os.Getenv("RUN_INTERVAL_DAYS")
12+
13+
func ShouldSkipExecution(DB db.DB) (bool, error) {
14+
date, err := DB.GetLastExecutionDate()
15+
if err != nil {
16+
return true, err
17+
}
18+
elapsedDays := int(time.Since(date).Hours() / 24)
19+
20+
interval, err := strconv.Atoi(runIntervalDays)
21+
if err != nil {
22+
return true, err
23+
}
24+
25+
return elapsedDays < interval, nil
26+
}

db/db.go

+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package db
2+
3+
import (
4+
"database/sql"
5+
"fmt"
6+
"os"
7+
"time"
8+
9+
"github.com/jordi-reinsma/bagel/model"
10+
_ "github.com/mattn/go-sqlite3"
11+
)
12+
13+
const (
14+
connString = "file:%s?_foreign_keys=on"
15+
sqliteDBPath = "./db/sql/bagel.sqlite"
16+
dbSchemaPath = "./db/sql/schema.sql"
17+
)
18+
19+
type DB struct {
20+
*sql.DB
21+
}
22+
23+
func MustConnect(reset bool) DB {
24+
if reset {
25+
fmt.Println("Resetting database")
26+
os.Remove(sqliteDBPath)
27+
}
28+
29+
conn, err := sql.Open("sqlite3", fmt.Sprintf(connString, sqliteDBPath))
30+
if err != nil {
31+
panic(err)
32+
}
33+
34+
db := DB{conn}
35+
36+
err = db.createTables()
37+
if err != nil {
38+
panic(err)
39+
}
40+
41+
return db
42+
}
43+
44+
func (db DB) createTables() error {
45+
schema, err := os.ReadFile(dbSchemaPath)
46+
if err != nil {
47+
return err
48+
}
49+
50+
_, err = db.Exec((string(schema)))
51+
return err
52+
}
53+
54+
func (db DB) AddAndGetUsers(users []model.User) ([]model.User, error) {
55+
query := "INSERT INTO users (uuid) VALUES (?) ON CONFLICT (uuid) DO UPDATE SET uuid=EXCLUDED.uuid RETURNING id"
56+
stmt, err := db.Prepare(query)
57+
if err != nil {
58+
return nil, err
59+
}
60+
61+
defer stmt.Close()
62+
63+
for i := range users {
64+
err = stmt.QueryRow(users[i].UUID).Scan(&users[i].ID)
65+
if err != nil {
66+
return nil, err
67+
}
68+
}
69+
return users, nil
70+
}
71+
72+
func (db DB) AddAndGetPairs(pairs []model.Match) ([]model.Match, error) {
73+
query := "INSERT INTO matches (a, b) VALUES (?, ?) ON CONFLICT (a, b) DO UPDATE SET a=EXCLUDED.a RETURNING id, freq"
74+
stmt, err := db.Prepare(query)
75+
if err != nil {
76+
return nil, err
77+
}
78+
79+
defer stmt.Close()
80+
81+
for i := range pairs {
82+
err = stmt.QueryRow(pairs[i].A.ID, pairs[i].B.ID).Scan(&pairs[i].ID, &pairs[i].Freq)
83+
if err != nil {
84+
return nil, err
85+
}
86+
}
87+
return pairs, nil
88+
}
89+
90+
func (db DB) UpdateMatch(match model.Match) error {
91+
query := "UPDATE matches SET freq = freq + 1 WHERE id = ?"
92+
_, err := db.Exec(query, match.ID)
93+
return err
94+
}
95+
96+
func (db DB) GetLastExecutionDate() (time.Time, error) {
97+
var date time.Time
98+
query := "SELECT date FROM executions ORDER BY id DESC LIMIT 1"
99+
err := db.QueryRow(query).Scan(&date)
100+
if err == sql.ErrNoRows {
101+
return time.Time{}, nil
102+
}
103+
return date, err
104+
}
105+
106+
func (db DB) SaveExecution() error {
107+
query := "INSERT INTO executions DEFAULT VALUES"
108+
_, err := db.Exec(query)
109+
return err
110+
}

db/sql/schema.sql

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
CREATE TABLE IF NOT EXISTS executions (
2+
id INTEGER PRIMARY KEY,
3+
date DATE NOT NULL DEFAULT CURRENT_DATE
4+
);
5+
6+
CREATE TABLE IF NOT EXISTS users (
7+
id INTEGER PRIMARY KEY,
8+
uuid VARCHAR (255) NOT NULL UNIQUE
9+
);
10+
11+
CREATE TABLE IF NOT EXISTS matches (
12+
id INTEGER PRIMARY KEY,
13+
a INTEGER NOT NULL CHECK (a < b) REFERENCES users (id),
14+
b INTEGER NOT NULL CHECK (a < b) REFERENCES users (id),
15+
freq INTEGER NOT NULL DEFAULT 0
16+
);
17+
CREATE UNIQUE INDEX IF NOT EXISTS matches_a_b_uidx ON matches (a, b);

go.mod

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module github.com/jordi-reinsma/bagel
2+
3+
go 1.17
4+
5+
require github.com/mattn/go-sqlite3 v1.14.8

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU=
2+
github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=

model/model.go

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package model
2+
3+
type User struct {
4+
ID int
5+
UUID string
6+
}
7+
8+
type Match struct {
9+
ID int
10+
A, B User
11+
Freq int
12+
}

0 commit comments

Comments
 (0)