Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,10 @@ slck messages send C1234567890 "Hello, *world*!"
# Send using --channel flag (alternative to positional arg)
slck messages send --channel general "Hello team"

# Send a direct message to a user (by user ID or @handle; requires im:write scope)
slck messages send U1234567890 "Hey, got a minute?"
slck messages send @alice "Hey, got a minute?"

# Send from stdin (use "-" as text argument)
echo "Hello from stdin" | slck messages send C1234567890 -
cat message.txt | slck messages send C1234567890 -
Expand Down
109 changes: 105 additions & 4 deletions internal/client/channel_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ import (
"strings"
)

// ResolveChannel takes a channel identifier (ID or name) and returns the channel ID.
// If the input looks like a channel ID (starts with C, G, or D), it's returned as-is.
// Otherwise, it's treated as a channel name and looked up via the Slack API.
// ResolveChannel takes a conversation identifier and returns the channel ID to
// act on. It accepts:
// - channel IDs (C/G/D...) returned as-is
// - channel names ("general", "#general") looked up via the Slack API
func (c *Client) ResolveChannel(channel string) (string, error) {
if channel == "" {
return "", fmt.Errorf("channel cannot be empty")
}

// Strip leading # if present (common user mistake)
channel = strings.TrimPrefix(channel, "#")

Expand All @@ -25,6 +30,32 @@ func (c *Client) ResolveChannel(channel string) (string, error) {
return c.lookupChannelByName(channel)
}

// ResolveMessageDestination takes a message destination and returns a Slack
// conversation ID suitable for message APIs. User IDs and handles are resolved
// by opening a DM; channel-like inputs are delegated to ResolveChannel.
func (c *Client) ResolveMessageDestination(destination string) (string, error) {
if destination == "" {
return "", fmt.Errorf("channel cannot be empty")
}

// "@handle" -> resolve the username to a user ID, then open a DM.
if handle := strings.TrimPrefix(destination, "@"); handle != destination {
userID, err := c.resolveUserHandle(handle)
if err != nil {
return "", err
}
return c.OpenDM(userID)
}

// A bare user ID (U.../W...) -> open a DM and return its IM channel ID.
// chat.postMessage rejects raw user IDs, so the DM must be opened first.
if IsUserID(destination) {
return c.OpenDM(destination)
}

return c.ResolveChannel(destination)
}

// IsChannelID returns true if the string looks like a Slack channel ID.
// Channel IDs start with:
// - C = public channel
Expand Down Expand Up @@ -54,12 +85,82 @@ func IsChannelID(s string) bool {
if c >= '0' && c <= '9' {
hasDigit = true
} else if c < 'A' || c > 'Z' {
return false // not uppercase letter or digit
return false // not an uppercase letter
}
}
return hasDigit
}

// IsUserID returns true if the string looks like a Slack user ID.
// User IDs start with:
// - U = regular user
// - W = Enterprise Grid user
//
// Like channel IDs, they are uppercase alphanumeric and contain at least one
// digit, which distinguishes them from plain words (e.g. "U" or "Us").
func IsUserID(s string) bool {
if len(s) < 2 {
return false
}
if s[0] != 'U' && s[0] != 'W' {
return false
}
rest := s[1:]
hasDigit := false
for _, c := range rest {
if c >= '0' && c <= '9' {
hasDigit = true
} else if c < 'A' || c > 'Z' {
return false // not an uppercase letter
}
}
return hasDigit
}

// resolveUserHandle resolves a Slack @handle to a user ID. The leading "@" may
// be included or omitted. It matches the username (the @name, which is unique)
// first, then falls back to display name, and errors if the handle is unknown
// or ambiguous.
func (c *Client) resolveUserHandle(handle string) (string, error) {
handle = strings.TrimPrefix(handle, "@")
if handle == "" {
return "", fmt.Errorf("user handle cannot be empty")
}

users, err := c.ListAllUsers()
if err != nil {
return "", fmt.Errorf("failed to list users: %w", err)
}

var byName, byDisplay []User
for _, u := range users {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low (harness-engineering:harness-enforcement-reviewer): resolveUserHandle always fetches every page of users before returning, even when a unique match is found on page 1. For large workspaces this is O(users/200) blocking API calls per @handle resolution. Early termination once ≥2 matches are accumulated would preserve the ambiguity-detection behavior while avoiding unnecessary pagination.

Reply to this thread when addressed.

switch {
case strings.EqualFold(u.Name, handle):
byName = append(byName, u)
case strings.EqualFold(u.Profile.DisplayName, handle):
byDisplay = append(byDisplay, u)
}
}

matches := byName
if len(matches) == 0 {
matches = byDisplay
}

switch len(matches) {
case 0:
return "", fmt.Errorf("user '@%s' not found. Use 'slck users search' to find a user", handle)
case 1:
return matches[0].ID, nil
default:
ids := make([]string, len(matches))
for i, u := range matches {
ids[i] = u.ID
}
return "", fmt.Errorf("user '@%s' is ambiguous (matches %s); use the user ID instead", handle, strings.Join(ids, ", "))
}
}

// lookupChannelByName searches for a channel by name and returns its ID.
func (c *Client) lookupChannelByName(name string) (string, error) {
// Normalize the name (lowercase, strip #)
Expand Down
246 changes: 246 additions & 0 deletions internal/client/channel_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,249 @@ func TestResolveChannel_Empty(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot be empty")
}

func TestIsUserID(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"U07D13N5AMV", true}, // regular user
{"U123456", true},
{"W012ABC34", true}, // Enterprise Grid user
{"USERNAME", false}, // pure letters - treated as a name
{"u07d13n5amv", false}, // lowercase
{"C123456", false}, // channel ID, not a user ID
{"B123456", false}, // bot ID
{"U", false}, // too short
{"", false},
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
assert.Equal(t, tt.expected, IsUserID(tt.input))
})
}
}

func TestResolveChannel_UserIDDoesNotOpenDM(t *testing.T) {
var openedDM bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/conversations.open":
openedDM = true
t.Errorf("ResolveChannel should not open DMs")
case "/conversations.list":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"channels": []map[string]interface{}{},
})
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()

c := NewWithConfig(server.URL, "test-token", nil)

_, err := c.ResolveChannel("U07D13N5AMV")
require.Error(t, err)
assert.False(t, openedDM)
assert.Contains(t, err.Error(), "channel 'u07d13n5amv' not found")
}

// TestResolveMessageDestination_UserID covers issue #152: a bare user ID must
// open a DM (conversations.open) rather than being lowercased and rejected as a
// channel.
func TestResolveMessageDestination_UserID(t *testing.T) {
var gotUsers string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/conversations.open", r.URL.Path)
var body struct {
Users string `json:"users"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
gotUsers = body.Users
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"channel": map[string]interface{}{"id": "D0123456789"},
})
}))
defer server.Close()

c := NewWithConfig(server.URL, "test-token", nil)

result, err := c.ResolveMessageDestination("U07D13N5AMV")
require.NoError(t, err)
assert.Equal(t, "D0123456789", result)
assert.Equal(t, "U07D13N5AMV", gotUsers, "user ID should be passed to conversations.open unchanged")
}

func TestResolveMessageDestination_EnterpriseGridUserID(t *testing.T) {
var gotUsers string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/conversations.open", r.URL.Path)
var body struct {
Users string `json:"users"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
gotUsers = body.Users
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"channel": map[string]interface{}{"id": "D0123456789"},
})
}))
defer server.Close()

c := NewWithConfig(server.URL, "test-token", nil)

result, err := c.ResolveMessageDestination("W012ABC34")
require.NoError(t, err)
assert.Equal(t, "D0123456789", result)
assert.Equal(t, "W012ABC34", gotUsers, "Enterprise Grid user ID should be passed through unchanged")
}

func TestResolveMessageDestination_Handle(t *testing.T) {
var gotOpenUsers string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/users.list":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"members": []map[string]interface{}{
{"id": "U111", "name": "alice"},
{"id": "U222", "name": "bob"},
},
})
case "/conversations.open":
var body struct {
Users string `json:"users"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
gotOpenUsers = body.Users
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"channel": map[string]interface{}{"id": "D0000000001"},
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low (harness-engineering:harness-knowledge-reviewer): The DisplayName single-match success path in resolveUserHandle has no test coverage. TestResolveMessageDestination_HandleAmbiguousAcrossPages covers the ambiguous display_name case and TestResolveMessageDestination_Handle covers the Name match, but a single unique DisplayName match (the byDisplay happy path) is never exercised. A regression in that branch would go undetected.

Reply to this thread when addressed.

default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()

c := NewWithConfig(server.URL, "test-token", nil)

result, err := c.ResolveMessageDestination("@alice")
require.NoError(t, err)
assert.Equal(t, "D0000000001", result)
assert.Equal(t, "U111", gotOpenUsers)
}

func TestResolveMessageDestination_HandleFoundAfterFirstPage(t *testing.T) {
userListCalls := 0
var gotOpenUsers string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/users.list":
userListCalls++
if userListCalls == 1 {
assert.Empty(t, r.URL.Query().Get("cursor"))
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"members": []map[string]interface{}{
{"id": "U111", "name": "bob"},
},
"response_metadata": map[string]string{"next_cursor": "cursor456"},
})
return
}
assert.Equal(t, "cursor456", r.URL.Query().Get("cursor"))
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"members": []map[string]interface{}{
{"id": "U222", "name": "alice"},
},
"response_metadata": map[string]string{"next_cursor": ""},
})
case "/conversations.open":
var body struct {
Users string `json:"users"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
gotOpenUsers = body.Users
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"channel": map[string]interface{}{"id": "D0000000002"},
})
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()

c := NewWithConfig(server.URL, "test-token", nil)

result, err := c.ResolveMessageDestination("@alice")
require.NoError(t, err)
assert.Equal(t, "D0000000002", result)
assert.Equal(t, 2, userListCalls)
assert.Equal(t, "U222", gotOpenUsers)
}

func TestResolveMessageDestination_HandleNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/users.list", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"members": []map[string]interface{}{{"id": "U111", "name": "alice"}},
})
}))
defer server.Close()

c := NewWithConfig(server.URL, "test-token", nil)

_, err := c.ResolveMessageDestination("@nobody")
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
assert.Contains(t, err.Error(), "slck users search")
}

func TestResolveMessageDestination_HandleAmbiguousAcrossPages(t *testing.T) {
userListCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
assert.Equal(t, "/users.list", r.URL.Path)
userListCalls++
if userListCalls == 1 {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"members": []map[string]interface{}{
{"id": "U111", "profile": map[string]interface{}{"display_name": "Sam"}},
},
"response_metadata": map[string]string{"next_cursor": "cursor456"},
})
return
}
assert.Equal(t, "cursor456", r.URL.Query().Get("cursor"))
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"members": []map[string]interface{}{
{"id": "U222", "profile": map[string]interface{}{"display_name": "Sam"}},
},
"response_metadata": map[string]string{"next_cursor": ""},
})
}))
defer server.Close()

c := NewWithConfig(server.URL, "test-token", nil)

_, err := c.ResolveMessageDestination("@Sam")
require.Error(t, err)
assert.Contains(t, err.Error(), "ambiguous")
assert.Equal(t, 2, userListCalls)
}
Loading
Loading