From cc9f39c40a893245b0c779b7aac90f24b2ec7e13 Mon Sep 17 00:00:00 2001 From: Alexander Goussas Date: Mon, 29 Sep 2025 12:09:47 -0500 Subject: [PATCH 1/1] init --- .gitignore | 3 ++ config/config.go | 33 ++++++++++++++++++++ database/init.sql | 12 ++++++++ docker-compose.yaml | 13 ++++++++ entities/squad_member.go | 7 +++++ go.mod | 11 +++++++ go.sum | 17 ++++++++++ main.go | 42 +++++++++++++++++++++++++ services/member_service.go | 46 +++++++++++++++++++++++++++ services/messaging_service.go | 58 +++++++++++++++++++++++++++++++++++ services/rotation_service.go | 47 ++++++++++++++++++++++++++++ 11 files changed, 289 insertions(+) create mode 100644 .gitignore create mode 100644 config/config.go create mode 100644 database/init.sql create mode 100644 docker-compose.yaml create mode 100644 entities/squad_member.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 services/member_service.go create mode 100644 services/messaging_service.go create mode 100644 services/rotation_service.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59c4474 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +squad-rotation-bot +db_data +.idea diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..79fd248 --- /dev/null +++ b/config/config.go @@ -0,0 +1,33 @@ +package config + +import ( + "fmt" + "os" +) + +type Config struct { + WebHookUrl string + DatabaseUrl string +} + +const ( + WEB_HOOK_URL = "WEB_HOOK_URL" + DATABASE_URL = "DATABASE_URL" +) + +func ReadConfig() (*Config, error) { + webHookUrl := os.Getenv(WEB_HOOK_URL) + if webHookUrl == "" { + return nil, fmt.Errorf("%s is not set", WEB_HOOK_URL) + } + + databaseUrl := os.Getenv(DATABASE_URL) + if databaseUrl == "" { + return nil, fmt.Errorf("%s is not set", DATABASE_URL) + } + + return &Config{ + WebHookUrl: webHookUrl, + DatabaseUrl: databaseUrl, + }, nil +} diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..748e011 --- /dev/null +++ b/database/init.sql @@ -0,0 +1,12 @@ +create table squad_members ( + id serial primary key, + full_name text not null, + avatar_url text +); + +create sequence squad_rotation + start 1 + increment 1 + minvalue 1 + no maxvalue + cache 1; \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..b9e44b6 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,13 @@ +services: + db: + image: "postgres:17" + restart: unless-stopped + environment: + POSTGRES_USER: "${DB_USER:-postgres}" + POSTGRES_PASSWORD: "${DB_PASSWORD:-postgres}" + POSTGRES_DB: "${DB_NAME:-postgres}" + ports: + - "5432:5432" + volumes: + - ./db_data:/var/lib/postgresql/data + - ./database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro diff --git a/entities/squad_member.go b/entities/squad_member.go new file mode 100644 index 0000000..02e06a2 --- /dev/null +++ b/entities/squad_member.go @@ -0,0 +1,7 @@ +package entities + +type SquadMember struct { + ID int + FullName string + AvatarUrl *string // Nullable +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8c867ac --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/aloussase/squad-rotation-bot + +go 1.25.1 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..58006f2 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..ae3d4b8 --- /dev/null +++ b/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "log" + + "github.com/aloussase/squad-rotation-bot/config" + "github.com/aloussase/squad-rotation-bot/services" + "github.com/jackc/pgx/v5" +) + +func main() { + config, err := config.ReadConfig() + if err != nil { + log.Fatalf("There was an error while reading the config: %s", err) + } + + conn, err := pgx.Connect(context.Background(), config.DatabaseUrl) + if err != nil { + log.Fatalf("There was an error while trying to connect to the database: %s", err) + } + + defer conn.Close(context.Background()) + + memberService := services.Create(conn) + rotationService := services.CreateRotationService(conn) + messagingService := services.CreateMessagingService(config) + + members, err := memberService.ListMembers() + if err != nil { + log.Fatalf("There was an error while trying to list members: %s", err) + } + + chosenOne, err := rotationService.ChooseNextInRotation(members) + if err != nil { + log.Fatalf("There was an error while trying to choose next in rotation: %s", err) + } + + if messagingService.SendRotationNotification(chosenOne) != nil { + log.Fatalf("There was an error while trying to send a rotation: %s", err) + } +} diff --git a/services/member_service.go b/services/member_service.go new file mode 100644 index 0000000..7f3a70d --- /dev/null +++ b/services/member_service.go @@ -0,0 +1,46 @@ +package services + +import ( + "context" + + "github.com/aloussase/squad-rotation-bot/entities" + "github.com/jackc/pgx/v5" +) + +type MemberService interface { + // ListMembers / List all members of the squad. + ListMembers() ([]entities.SquadMember, error) +} + +type memberServiceImpl struct { + conn *pgx.Conn +} + +// Create / Create a new instance of MemberService. +func Create(conn *pgx.Conn) MemberService { + return &memberServiceImpl{ + conn: conn, + } +} + +func (ms *memberServiceImpl) ListMembers() ([]entities.SquadMember, error) { + query := "select (id, full_name, avatar_url) from squad_members" + rows, err := ms.conn.Query(context.Background(), query) + if err != nil { + return nil, err + } + + defer rows.Close() + + members, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (entities.SquadMember, error) { + var member entities.SquadMember + err := row.Scan(&member) + return member, err + }) + + if err != nil { + return nil, err + } + + return members, nil +} diff --git a/services/messaging_service.go b/services/messaging_service.go new file mode 100644 index 0000000..7a8d454 --- /dev/null +++ b/services/messaging_service.go @@ -0,0 +1,58 @@ +package services + +import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/aloussase/squad-rotation-bot/config" + "github.com/aloussase/squad-rotation-bot/entities" +) + +type MessagingService interface { + SendRotationNotification(member entities.SquadMember) error +} + +type messagingServiceImpl struct { + config *config.Config +} + +func CreateMessagingService(config *config.Config) MessagingService { + return &messagingServiceImpl{config} +} + +func (m *messagingServiceImpl) SendRotationNotification(member entities.SquadMember) error { + payload := map[string]any{ + "cards": map[string]any{ + "header": map[string]any{ + "title": "Today's Presenter 🎤", + "imageUrl": member.AvatarUrl, + }, + "sections": []any{ + map[string]any{ + "widgets": []any{ + map[string]any{ + "textParagraph": map[string]any{ + "text": member.FullName, + }, + }, + }, + }, + }, + }, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + + resp, err := http.Post(m.config.WebHookUrl, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + defer resp.Body.Close() + + return nil +} diff --git a/services/rotation_service.go b/services/rotation_service.go new file mode 100644 index 0000000..7854d9f --- /dev/null +++ b/services/rotation_service.go @@ -0,0 +1,47 @@ +package services + +import ( + "context" + "fmt" + "slices" + + "github.com/aloussase/squad-rotation-bot/entities" + "github.com/jackc/pgx/v5" +) + +type RotationService interface { + /// Choose the next member in rotation. If the input list is empty, an error is returned. + ChooseNextInRotation(members []entities.SquadMember) (entities.SquadMember, error) +} + +type rotationServiceImpl struct { + conn *pgx.Conn +} + +func CreateRotationService(conn *pgx.Conn) RotationService { + return &rotationServiceImpl{ + conn: conn, + } +} + +func (rs *rotationServiceImpl) ChooseNextInRotation(members []entities.SquadMember) (entities.SquadMember, error) { + if len(members) == 0 { + return entities.SquadMember{}, fmt.Errorf("list of members if empty") + } + + var next int + + err := rs.conn.QueryRow(context.Background(), "select nextval('squad_rotation')").Scan(&next) + if err != nil { + return entities.SquadMember{}, err + } + + // Sort the members to ensure a deterministic pick. + sortedMembers := slices.SortedFunc(slices.Values(members), func(a, b entities.SquadMember) int { + return a.ID - b.ID + }) + + chosen := sortedMembers[next%len(sortedMembers)] + + return chosen, nil +} -- 2.43.0