--- /dev/null
+squad-rotation-bot
+db_data
+.idea
--- /dev/null
+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
+}
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+package entities
+
+type SquadMember struct {
+ ID int
+ FullName string
+ AvatarUrl *string // Nullable
+}
--- /dev/null
+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
+)
--- /dev/null
+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=
--- /dev/null
+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)
+ }
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}