Skip to content

Commit c965c11

Browse files
authored
Merge pull request #67 from codeGROOVE-dev/reliable
Don't duplicate DMs
2 parents be980f3 + a52c670 commit c965c11

File tree

7 files changed

+66
-13
lines changed

7 files changed

+66
-13
lines changed

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ require (
66
github.com/codeGROOVE-dev/ds9 v0.6.0
77
github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251024133418-149270eb16a9
88
github.com/codeGROOVE-dev/gsm v0.0.0-20251019065141-833fe2363d22
9-
github.com/codeGROOVE-dev/prx v0.0.0-20251028202628-9f237ee71356
9+
github.com/codeGROOVE-dev/prx v0.0.0-20251030022101-ff906928a1e4
1010
github.com/codeGROOVE-dev/retry v1.3.0
1111
github.com/codeGROOVE-dev/sprinkler v0.0.0-20251029020504-57f2ea3ae37a
12-
github.com/codeGROOVE-dev/turnclient v0.0.0-20251028130307-1f85c9aa43c4
12+
github.com/codeGROOVE-dev/turnclient v0.0.0-20251030022425-bc3b14acf75e
1313
github.com/golang-jwt/jwt/v5 v5.3.0
1414
github.com/google/go-github/v50 v50.2.0
15+
github.com/google/uuid v1.6.0
1516
github.com/gorilla/mux v1.8.1
1617
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7
1718
github.com/slack-go/slack v0.17.3
@@ -26,7 +27,6 @@ require (
2627
github.com/cloudflare/circl v1.6.1 // indirect
2728
github.com/google/go-cmp v0.7.0 // indirect
2829
github.com/google/go-querystring v1.1.0 // indirect
29-
github.com/google/uuid v1.6.0 // indirect
3030
github.com/gorilla/websocket v1.5.3 // indirect
3131
github.com/kr/pretty v0.3.1 // indirect
3232
github.com/rogpeppe/go-internal v1.14.1 // indirect

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251024133418-149270eb16a9 h1:eyWcEZ
88
github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251024133418-149270eb16a9/go.mod h1:4Hr2ySB8dcpeZqZq/7UbXdEJ/5RK9coYGHvW90ZfieE=
99
github.com/codeGROOVE-dev/gsm v0.0.0-20251019065141-833fe2363d22 h1:gtN3rOc6YspO646BkcOxBhPjEqKUz+jl175jIqglfDg=
1010
github.com/codeGROOVE-dev/gsm v0.0.0-20251019065141-833fe2363d22/go.mod h1:KV+w19ubP32PxZPE1hOtlCpTaNpF0Bpb32w5djO8UTg=
11-
github.com/codeGROOVE-dev/prx v0.0.0-20251028202628-9f237ee71356 h1:lHoHnylLAp7/7BMhdiTh9Z2+p4ATcQ7aFcgqxOFGzE4=
12-
github.com/codeGROOVE-dev/prx v0.0.0-20251028202628-9f237ee71356/go.mod h1:FEy3gz9IYDXWnKWkoDSL+pWu6rujxbBSrF4w5A8QSK0=
11+
github.com/codeGROOVE-dev/prx v0.0.0-20251030022101-ff906928a1e4 h1:DSuoUwP3oyR4cHrX0cUh9c7CtYjXNIcyCmqpIwHilIU=
12+
github.com/codeGROOVE-dev/prx v0.0.0-20251030022101-ff906928a1e4/go.mod h1:FEy3gz9IYDXWnKWkoDSL+pWu6rujxbBSrF4w5A8QSK0=
1313
github.com/codeGROOVE-dev/retry v1.3.0 h1:/+ipAWRJLL6y1R1vprYo0FSjSBvH6fE5j9LKXjpD54g=
1414
github.com/codeGROOVE-dev/retry v1.3.0/go.mod h1:8OgefgV1XP7lzX2PdKlCXILsYKuz6b4ZpHa/20iLi8E=
1515
github.com/codeGROOVE-dev/sprinkler v0.0.0-20251029020504-57f2ea3ae37a h1:iLjcbNpCKXT8ZAlGGesh0x9g/ntgG5OCuvM2QJ9+27E=
1616
github.com/codeGROOVE-dev/sprinkler v0.0.0-20251029020504-57f2ea3ae37a/go.mod h1:/kd3ncsRNldD0MUpbtp5ojIzfCkyeXB7JdOrpuqG7Gg=
17-
github.com/codeGROOVE-dev/turnclient v0.0.0-20251028130307-1f85c9aa43c4 h1:si9tMEo5SXpDuDXGkJ1zNnnpP8TbmakrkNujAbpKlqA=
18-
github.com/codeGROOVE-dev/turnclient v0.0.0-20251028130307-1f85c9aa43c4/go.mod h1:bFWMd0JeaJY0kSIO5AcRQdJLXF3Fo3eKclE49vmIZes=
17+
github.com/codeGROOVE-dev/turnclient v0.0.0-20251030022425-bc3b14acf75e h1:WXHdC8o5KmP5CwkQRiGVywYzsj93fjkRPq7clhfZPq0=
18+
github.com/codeGROOVE-dev/turnclient v0.0.0-20251030022425-bc3b14acf75e/go.mod h1:dVS3MlJDgL6WkfurJAyS7I9Fe1yxxoxxarjVifY5bIo=
1919
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
2020
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2121
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

pkg/notify/interfaces.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type SlackClient interface {
2525

2626
// DM operations
2727
SendDirectMessage(ctx context.Context, userID, text string) (dmChannelID, messageTS string, err error)
28+
UpdateDMMessage(ctx context.Context, userID, prURL, newText string) error
2829
HasRecentDMAboutPR(ctx context.Context, userID, prURL string) (bool, error)
2930
SaveDMMessageInfo(ctx context.Context, userID, prURL, dmChannelID, messageTS, message string) error
3031

pkg/notify/notify.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ package notify
44
import (
55
"context"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"log/slog"
910
"sort"
1011
"strings"
1112
"time"
1213

14+
"github.com/codeGROOVE-dev/slacker/pkg/slack"
1315
"github.com/codeGROOVE-dev/slacker/pkg/state"
1416
"github.com/codeGROOVE-dev/turnclient/pkg/turn"
1517
"github.com/google/uuid"
@@ -881,6 +883,33 @@ func (m *Manager) NotifyUser(ctx context.Context, workspaceID, userID, channelID
881883
"action_required", action,
882884
"message", message)
883885

886+
// Try to update existing DM first (if one exists in our state store)
887+
// UpdateDMMessage returns ErrNoDMToUpdate if no DM exists
888+
updateErr := slackClient.UpdateDMMessage(ctx, userID, pr.HTMLURL, message)
889+
if updateErr == nil {
890+
// Successfully updated existing DM
891+
slog.Info("successfully updated existing DM with new PR state",
892+
"user", userID,
893+
"pr", fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number),
894+
"pr_state", pr.State,
895+
"action_required", action)
896+
return nil
897+
}
898+
899+
// Check if it's because no DM exists (expected case for first notification)
900+
if !errors.Is(updateErr, slack.ErrNoDMToUpdate) {
901+
// Actual error occurred during update - log warning but continue to send new DM
902+
slog.Warn("failed to update existing DM, will send new one",
903+
"user", userID,
904+
"pr", fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number),
905+
"error", updateErr)
906+
}
907+
908+
slog.Debug("no existing DM to update, will check for recent DMs and potentially send new one",
909+
"user", userID,
910+
"pr", fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number),
911+
"reason", "DM not in state store or too old")
912+
884913
// Check if we recently sent a DM about this PR (prevents duplicates during rolling deployments)
885914
hasRecent, err := slackClient.HasRecentDMAboutPR(ctx, userID, pr.HTMLURL)
886915
if err != nil {

pkg/notify/notify_user_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"testing"
77
"time"
88

9+
"github.com/codeGROOVE-dev/slacker/pkg/slack"
910
"github.com/codeGROOVE-dev/slacker/pkg/state"
1011
slackapi "github.com/slack-go/slack"
1112
)
@@ -16,6 +17,7 @@ type mockSlackClient struct {
1617
isUserInChannelFunc func(ctx context.Context, channelID, userID string) bool
1718
userTimezoneFunc func(ctx context.Context, userID string) (string, error)
1819
sendDirectMessageFunc func(ctx context.Context, userID, text string) (dmChannelID, messageTS string, err error)
20+
updateDMMessageFunc func(ctx context.Context, userID, prURL, newText string) error
1921
hasRecentDMAboutPRFunc func(ctx context.Context, userID, prURL string) (bool, error)
2022
saveDMMessageInfoFunc func(ctx context.Context, userID, prURL, dmChannelID, messageTS, message string) error
2123
apiFunc func() *slackapi.Client
@@ -49,6 +51,14 @@ func (m *mockSlackClient) SendDirectMessage(ctx context.Context, userID, text st
4951
return "D123", "1234567890.123456", nil
5052
}
5153

54+
func (m *mockSlackClient) UpdateDMMessage(ctx context.Context, userID, prURL, newText string) error {
55+
if m.updateDMMessageFunc != nil {
56+
return m.updateDMMessageFunc(ctx, userID, prURL, newText)
57+
}
58+
// Default: return ErrNoDMToUpdate (no DM exists to update)
59+
return slack.ErrNoDMToUpdate
60+
}
61+
5262
func (m *mockSlackClient) HasRecentDMAboutPR(ctx context.Context, userID, prURL string) (bool, error) {
5363
if m.hasRecentDMAboutPRFunc != nil {
5464
return m.hasRecentDMAboutPRFunc(ctx, userID, prURL)

pkg/slack/client_additional_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ func TestUpdateDMMessage(t *testing.T) {
1818

1919
prURL := "https://github.com/test/repo/pull/123"
2020
err := client.UpdateDMMessage(ctx, "U001", prURL, "New text")
21-
// Should not error when state store is nil (graceful degradation)
22-
if err != nil {
23-
t.Fatalf("unexpected error: %v", err)
21+
// Should return ErrNoDMToUpdate when state store is nil
22+
if !errors.Is(err, ErrNoDMToUpdate) {
23+
t.Fatalf("expected ErrNoDMToUpdate, got: %v", err)
2424
}
2525
})
2626
}

pkg/slack/slack.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"crypto/sha256"
99
"encoding/hex"
1010
"encoding/json"
11+
"errors"
1112
"fmt"
1213
"io"
1314
"log/slog"
@@ -24,6 +25,12 @@ import (
2425
"github.com/slack-go/slack/slackevents"
2526
)
2627

28+
// Errors
29+
var (
30+
// ErrNoDMToUpdate indicates no DM exists to update.
31+
ErrNoDMToUpdate = errors.New("no DM found to update")
32+
)
33+
2734
// Constants for input validation.
2835
const (
2936
maxCommandInputLength = 200
@@ -460,9 +467,14 @@ func (c *Client) SendDirectMessage(ctx context.Context, userID, text string) (dm
460467

461468
var msgTS string
462469
// Then send message with retry
470+
// Disable unfurling for GitHub links in DMs.
471+
options := []slack.MsgOption{
472+
slack.MsgOptionText(text, false),
473+
slack.MsgOptionDisableLinkUnfurl(),
474+
}
463475
err = retry.Do(
464476
func() error {
465-
_, ts, err := c.api.PostMessageContext(ctx, channelID, slack.MsgOptionText(text, false))
477+
_, ts, err := c.api.PostMessageContext(ctx, channelID, options...)
466478
if err != nil {
467479
if isRateLimitError(err) {
468480
slog.Warn("rate limited sending DM, backing off", "user", userID)
@@ -522,14 +534,15 @@ func (c *Client) SaveDMMessageInfo(ctx context.Context, userID, prURL, channelID
522534
}
523535

524536
// UpdateDMMessage updates a previously sent DM message.
537+
// Returns nil and logs debug if no DM exists, returns error if update fails.
525538
func (c *Client) UpdateDMMessage(ctx context.Context, userID, prURL, newText string) error {
526539
c.stateStoreMu.RLock()
527540
store := c.stateStore
528541
c.stateStoreMu.RUnlock()
529542

530543
if store == nil {
531544
slog.Debug("no state store configured, cannot update DM", "user", userID, "pr_url", prURL)
532-
return nil
545+
return ErrNoDMToUpdate
533546
}
534547

535548
// Get stored DM message info
@@ -539,7 +552,7 @@ func (c *Client) UpdateDMMessage(ctx context.Context, userID, prURL, newText str
539552
"user", userID,
540553
"pr_url", prURL,
541554
"reason", "never sent or too old")
542-
return nil
555+
return ErrNoDMToUpdate
543556
}
544557

545558
// Update the message using Slack API

0 commit comments

Comments
 (0)