Skip to content

Commit

Permalink
bot will now notify participants who did not get matched
Browse files Browse the repository at this point in the history
  • Loading branch information
bincyber committed Aug 31, 2024
1 parent f147a41 commit 46fd519
Show file tree
Hide file tree
Showing 12 changed files with 392 additions and 3 deletions.
Binary file added docs/images/screenshots/notify-member.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 32 additions & 2 deletions internal/bot/job_create_matches.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"github.com/hashicorp/go-hclog"
"github.com/pkg/errors"
"github.com/slack-go/slack"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm"

"github.com/chat-roulettte/chat-roulette/internal/database/models"
Expand Down Expand Up @@ -57,12 +59,31 @@ func CreateMatches(ctx context.Context, db *gorm.DB, client *slack.Client, p *Cr
}
logger.Debug("retrieved matches for chat-roulette", "matches", result.RowsAffected)

// Create a database record in the matches table for each pair and queue a CREATE_PAIR job
var unpaired int
for _, pair := range matches {
// Queue a NOTIFY_MEMBER job for any participants who did not get matched
if pair.Partner == "" {
params := &NotifyMemberParams{
ChannelID: p.ChannelID,
UserID: pair.Participant,
}

dbCtx, cancel = context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()

if err := QueueNotifyMemberJob(dbCtx, db, params); err != nil {
message := "failed to add CREATE_PAIR job to the queue"
logger.Error(message, "error", result.Error)
return errors.Wrap(result.Error, message)
}
logger.Info("queued NOTIFY_MEMBER job for this unmatched participant")

unpaired++

continue
}

// Create a database record in the matches table for each pair and queue a CREATE_PAIR job
newMatch := &models.Match{
RoundID: p.RoundID,
}
Expand Down Expand Up @@ -96,7 +117,16 @@ func CreateMatches(ctx context.Context, db *gorm.DB, client *slack.Client, p *Cr
logger.Info("queued CREATE_PAIR job for this match", "match_id", newMatch.ID)
}

logger.Info("paired active participants for chat-roulette", "participants", len(matches)*2, "pairings", len(matches))
pairsCount := len(matches) - unpaired
participantsCount := (pairsCount*2 + unpaired)

logger.Info("paired active participants for chat-roulette", "participants", participantsCount, "pairs", pairsCount, "unpaired", unpaired)

trace.SpanFromContext(ctx).SetAttributes(
attribute.Int("participants", participantsCount),
attribute.Int("pairs", pairsCount),
attribute.Int("unpaired", unpaired),
)

return nil
}
Expand Down
9 changes: 9 additions & 0 deletions internal/bot/job_create_matches_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,28 @@ func (s *CreateMatchesSuite) Test_CreateMatches() {
ChannelID: channelID,
})

// Test
err = CreateMatches(s.ctx, db, nil, &CreateMatchesParams{
ChannelID: channelID,
RoundID: 1,
})
r.NoError(err)
r.Contains(s.buffer.String(), "added new match to the database")
r.Contains(s.buffer.String(), "paired active participants for chat-roulette")
r.Contains(s.buffer.String(), "participants=5")
r.Contains(s.buffer.String(), "pairs=2")
r.Contains(s.buffer.String(), "unpaired=1")

// Verify matches
var count int64
result := db.Model(&models.Job{}).Where("job_type = ?", models.JobTypeCreatePair).Count(&count)
r.NoError(result.Error)
r.Equal(int64(2), count)

// Verify unmatched participants were notified
result = db.Model(&models.Job{}).Where("job_type = ?", models.JobTypeNotifyMember).Count(&count)
r.NoError(result.Error)
r.Equal(int64(1), count)
}

func (s *CreateMatchesSuite) Test_QueueCreateMatchesJob() {
Expand Down
114 changes: 114 additions & 0 deletions internal/bot/job_notify_member.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package bot

import (
"context"
"encoding/json"
"time"

"github.com/hashicorp/go-hclog"
"github.com/pkg/errors"
"github.com/slack-go/slack"
"gorm.io/gorm"

"github.com/chat-roulettte/chat-roulette/internal/database/models"
"github.com/chat-roulettte/chat-roulette/internal/o11y/attributes"
)

const (
notifyMemberTemplateFilename = "notify_member.json.tmpl"
)

// notifyMemberTemplate is used with templates/notify_member.json.tmpl
type notifyMemberTemplate struct {
ChannelID string
UserID string
NextRound time.Time
}

// NotifyMemberParams are the parameters for the NOTIFY_MEMBER job.
type NotifyMemberParams struct {
ChannelID string `json:"channel_id"`
UserID string `json:"user_id"`
}

// NotifyMember sends an apologetic message to a participant for not being able to be matched in this round of chat roulette.
func NotifyMember(ctx context.Context, db *gorm.DB, client *slack.Client, p *NotifyMemberParams) error {

logger := hclog.FromContext(ctx).With(
attributes.SlackChannelID, p.ChannelID,
attributes.SlackUserID, p.UserID,
)

// Retrieve channel metadata from the database
dbCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()

var channel models.Channel

if err := db.WithContext(dbCtx).Where("channel_id = ?", p.ChannelID).First(&channel).Error; err != nil {
message := "failed to retrieve metadata for the Slack channel"
logger.Error(message, "error", err)
return errors.Wrap(err, message)
}

// Render template
t := notifyMemberTemplate{
ChannelID: p.ChannelID,
UserID: p.UserID,
NextRound: channel.NextRound,
}

content, err := renderTemplate(notifyMemberTemplateFilename, t)
if err != nil {
return errors.Wrap(err, "failed to render template")
}

logger.Info("notifying Slack member with a message")

// We can marshal the json template into View as it contains Blocks
var view slack.View
if err := json.Unmarshal([]byte(content), &view); err != nil {
return errors.Wrap(err, "failed to unmarshal JSON")
}

// Open a Slack DM with the user
childCtx, cancel := context.WithTimeout(ctx, 3000*time.Millisecond)
defer cancel()

response, _, _, err := client.OpenConversationContext(
childCtx,
&slack.OpenConversationParameters{
ReturnIM: false,
Users: []string{
p.UserID,
},
})

if err != nil {
logger.Error("failed to open Slack DM", "error", err)
return err
}

// Send the Slack DM to the user
if _, _, err = client.PostMessageContext(
ctx,
response.Conversation.ID,
slack.MsgOptionBlocks(view.Blocks.BlockSet...),
); err != nil {
logger.Error("failed to send Slack direct message", "error", err)
return err
}

return nil
}

// QueueNotifyMemberJob adds a new NOTIFY_MEMBER job to the queue.
func QueueNotifyMemberJob(ctx context.Context, db *gorm.DB, p *NotifyMemberParams) error {
job := models.GenericJob[*NotifyMemberParams]{
JobType: models.JobTypeNotifyMember,
Priority: models.JobPriorityStandard,
Params: p,
}

return QueueJob(ctx, db, job)
}
161 changes: 161 additions & 0 deletions internal/bot/job_notify_member_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package bot

import (
"bytes"
"context"
"database/sql/driver"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/DATA-DOG/go-sqlmock"
"github.com/hashicorp/go-hclog"
"github.com/sebdah/goldie/v2"
"github.com/slack-go/slack"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"gorm.io/gorm"

"github.com/chat-roulettte/chat-roulette/internal/database"
"github.com/chat-roulettte/chat-roulette/internal/database/models"
"github.com/chat-roulettte/chat-roulette/internal/o11y"
)

type NotifyMemberSuite struct {
suite.Suite
ctx context.Context
mock sqlmock.Sqlmock
db *gorm.DB
logger hclog.Logger
buffer *bytes.Buffer
}

func (s *NotifyMemberSuite) SetupTest() {
s.logger, s.buffer = o11y.NewBufferedLogger()
s.ctx = hclog.WithContext(context.Background(), s.logger)
s.db, s.mock = database.NewMockedGormDB()
}

func (s *NotifyMemberSuite) AfterTest(_, _ string) {
require.NoError(s.T(), s.mock.ExpectationsWereMet())
}

func (s *NotifyMemberSuite) Test_NotifyMember() {
r := require.New(s.T())

p := &NotifyMemberParams{
ChannelID: "C9876543210",
UserID: "U0123456789",
}

columns := []string{
"channel_id",
"inviter",
"interval",
"weekday",
"hour",
"next_round",
}

row := []driver.Value{
p.ChannelID,
"U8967452301",
models.Weekly,
time.Sunday,
12,
time.Now(),
}

// Mock retrieving channel metadata
s.mock.ExpectQuery(`SELECT \* FROM "channels" WHERE channel_id = (.+) ORDER BY`).
WithArgs(
p.ChannelID,
1,
).
WillReturnRows(sqlmock.NewRows(columns).AddRow(row...))

// Mock Slack API calls
mux := http.NewServeMux()

mux.HandleFunc("/conversations.open", func(w http.ResponseWriter, req *http.Request) {
w.Write([]byte(`{"ok":true,"channel":{"id":"D1111111111"}}`))
})

mux.HandleFunc("/chat.postMessage", func(w http.ResponseWriter, req *http.Request) {
req.ParseForm()

b := req.FormValue("blocks")

if b == "" {
w.WriteHeader(http.StatusBadRequest)
return
}

var blocks slack.Blocks
if err := json.Unmarshal([]byte(b), &blocks); err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"ok":false}`))
}

r.Len(blocks.BlockSet, 4)

w.Write([]byte(`{
"ok": true,
"channel": "D1111111111"
}`))
})

httpServer := httptest.NewServer(mux)
defer httpServer.Close()

url := fmt.Sprintf("%s/", httpServer.URL)
client := slack.New("xoxb-test-token-here", slack.OptionAPIURL(url))

err := NotifyMember(s.ctx, s.db, client, p)
r.NoError(err)
}

func (s *NotifyMemberSuite) Test_NotifyMemberJob() {
r := require.New(s.T())

p := &NotifyMemberParams{
ChannelID: "C0123456789",
UserID: "U1111111111",
}

database.MockQueueJob(
s.mock,
p,
models.JobTypeNotifyMember.String(),
models.JobPriorityStandard,
)

err := QueueNotifyMemberJob(s.ctx, s.db, p)
r.NoError(err)
r.Contains(s.buffer.String(), "added new job to the database")
}

func Test_NotifyMember_suite(t *testing.T) {
suite.Run(t, new(NotifyMemberSuite))
}

func Test_notifyMemberTemplate(t *testing.T) {
g := goldie.New(t)

nextRound := time.Date(2022, time.January, 3, 12, 0, 0, 0, time.UTC)

p := notifyMemberTemplate{
ChannelID: "C0123456789",
UserID: "U0123456789",
NextRound: nextRound,
}

content, err := renderTemplate(notifyMemberTemplateFilename, p)
assert.Nil(t, err)

g.Assert(t, "notify_member.json", []byte(content))
}
32 changes: 32 additions & 0 deletions internal/bot/templates/notify_member.json.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Hi <@{{ .UserID }}> :wave:"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "A new round of Chat Roulette has started for <#{{ .ChannelID }}>!"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "I'm really sorry, I was unable to match you with another participant this time :cry:"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "The next Chat Roulette round will be on *{{ .NextRound | prettyDate }}*. I'm hopeful that I will be able to connect you with another participant then!"
}
}
]
}
Loading

0 comments on commit 46fd519

Please sign in to comment.