]> git.frustrated-labs.net Git - squad-rotation-bot.git/commitdiff
init
authorAlexander Goussas <[email protected]>
Mon, 29 Sep 2025 17:09:47 +0000 (12:09 -0500)
committerAlexander Goussas <[email protected]>
Mon, 29 Sep 2025 17:09:47 +0000 (12:09 -0500)
.gitignore [new file with mode: 0644]
config/config.go [new file with mode: 0644]
database/init.sql [new file with mode: 0644]
docker-compose.yaml [new file with mode: 0644]
entities/squad_member.go [new file with mode: 0644]
go.mod [new file with mode: 0644]
go.sum [new file with mode: 0644]
main.go [new file with mode: 0644]
services/member_service.go [new file with mode: 0644]
services/messaging_service.go [new file with mode: 0644]
services/rotation_service.go [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..59c4474
--- /dev/null
@@ -0,0 +1,3 @@
+squad-rotation-bot
+db_data
+.idea
diff --git a/config/config.go b/config/config.go
new file mode 100644 (file)
index 0000000..79fd248
--- /dev/null
@@ -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 (file)
index 0000000..748e011
--- /dev/null
@@ -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 (file)
index 0000000..b9e44b6
--- /dev/null
@@ -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 (file)
index 0000000..02e06a2
--- /dev/null
@@ -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 (file)
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 (file)
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 (file)
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 (file)
index 0000000..7f3a70d
--- /dev/null
@@ -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 (file)
index 0000000..7a8d454
--- /dev/null
@@ -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 (file)
index 0000000..7854d9f
--- /dev/null
@@ -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
+}