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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,10 @@ slck channels set-purpose C1234567890 "Channel purpose"

# Invite users
slck channels invite C1234567890 U1111111111 U2222222222

# Join or leave a channel yourself (to invite others to a channel you are not in, join it first)
slck channels join C1234567890 --as-user
slck channels leave C1234567890 --as-user
```

#### Channels Command Reference
Expand All @@ -587,6 +591,8 @@ slck channels invite C1234567890 U1111111111 U2222222222
| `set-topic <id> <topic>` | | Set channel topic |
| `set-purpose <id> <purpose>` | | Set channel purpose |
| `invite <id> <user>...` | | Invite users to channel |
| `join <id>` | | Join a public channel as the token's identity (idempotent) |
| `leave <id>` | | Leave a channel as the token's identity (idempotent) |

### Users

Expand Down
11 changes: 11 additions & 0 deletions integration-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,17 @@ Using **NEW_CHANNEL_ID** from step 5.1:
| 3 | `slck channels unarchive <NEW_CHANNEL_ID>` | "Unarchived channel" |
| 4 | `slck channels unarchive <NEW_CHANNEL_ID>` | "Channel not archived" (idempotent, exit 0) |

### 5.3a Join & Leave

Using a public channel the user token's account is not in:

| Step | Command | Expected |
|------|---------|----------|
| 1 | `slck channels join <CHANNEL> --as-user` | "Joined channel" |
| 2 | `slck channels join <CHANNEL> --as-user` | "Already in channel" (idempotent, exit 0) |
| 3 | `slck channels leave <CHANNEL> --as-user` | "Left channel" |
| 4 | `slck channels leave <CHANNEL> --as-user` | "Not in channel" (idempotent, exit 0) |

### 5.4 Cleanup: Archive Test Channels

| Step | Command | Expected |
Expand Down
37 changes: 37 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"os"
"slices"
"strconv"
"time"

Expand Down Expand Up @@ -953,6 +954,42 @@ func (c *Client) UnarchiveChannel(channel string) error {
return err
}

// JoinChannel adds the caller to a public channel. It reports whether the
// caller was already a member, which Slack signals with an
// already_in_channel warning rather than an error, alongside any others.
func (c *Client) JoinChannel(channel string) (alreadyMember bool, err error) {
body, err := c.post("conversations.join", map[string]interface{}{"channel": channel})
if err != nil {
return false, err
}
var resp struct {
ResponseMetadata struct {
Warnings []string `json:"warnings"`
} `json:"response_metadata"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return false, err
}
return slices.Contains(resp.ResponseMetadata.Warnings, "already_in_channel"), nil
Comment thread
piekstra marked this conversation as resolved.
}

// LeaveChannel removes the caller from a channel. It reports whether the
// caller was not a member, which Slack signals with not_in_channel on a
// successful response.
func (c *Client) LeaveChannel(channel string) (notMember bool, err error) {
body, err := c.post("conversations.leave", map[string]interface{}{"channel": channel})
if err != nil {
return false, err
}
var resp struct {
NotInChannel bool `json:"not_in_channel"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return false, err
}
return resp.NotInChannel, nil
}

// SetChannelTopic sets the channel topic
func (c *Client) SetChannelTopic(channel, topic string) error {
data := map[string]interface{}{
Expand Down
53 changes: 53 additions & 0 deletions internal/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1707,3 +1707,56 @@ func TestClient_GetPermalink_Error(t *testing.T) {
t.Fatal("expected an error for a not-OK response, got nil")
}
}

func membershipServer(t *testing.T, reply string) *Client {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(reply))
}))
t.Cleanup(server.Close)
return NewWithConfig(server.URL, "test-token", nil)
}

func TestJoinChannelReportsExistingMembership(t *testing.T) {
cases := map[string]struct {
reply string
want bool
}{
"joined": {`{"ok":true}`, false},
// Slack's reply for an existing member, with the warning among others.
"already member": {`{"ok":true,"warning":"missing_charset,already_in_channel","response_metadata":{"warnings":["missing_charset","already_in_channel"]}}`, true},
"other warning only": {`{"ok":true,"warning":"missing_charset","response_metadata":{"warnings":["missing_charset"]}}`, false},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
got, err := membershipServer(t, c.reply).JoinChannel("C123")
if err != nil {
t.Fatal(err)
}
if got != c.want {
t.Errorf("alreadyMember = %v, want %v", got, c.want)
}
})
}
}

func TestLeaveChannelReportsMissingMembership(t *testing.T) {
cases := map[string]struct {
reply string
want bool
}{
"left": {`{"ok":true}`, false},
"not member": {`{"ok":true,"not_in_channel":true}`, true},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
got, err := membershipServer(t, c.reply).LeaveChannel("C123")
if err != nil {
t.Fatal(err)
}
if got != c.want {
t.Errorf("notMember = %v, want %v", got, c.want)
}
})
}
}
2 changes: 2 additions & 0 deletions internal/cmd/channels/channels.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ func NewCmd() *cobra.Command {
cmd.AddCommand(newSetTopicCmd())
cmd.AddCommand(newSetPurposeCmd())
cmd.AddCommand(newInviteCmd())
cmd.AddCommand(newJoinCmd())
cmd.AddCommand(newLeaveCmd())

return cmd
}
52 changes: 52 additions & 0 deletions internal/cmd/channels/channels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,3 +485,55 @@ func TestRunUnarchive_NotInChannel(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "not_in_channel")
}

func slackStub(t *testing.T, path string, reply map[string]interface{}) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, path, r.URL.Path)
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
assert.Equal(t, "C123", body["channel"])
_ = json.NewEncoder(w).Encode(reply)
}))
t.Cleanup(server.Close)
return server
}

func TestRunJoin(t *testing.T) {
for name, reply := range map[string]map[string]interface{}{
"joins": {"ok": true},
"already member": {"ok": true, "warning": "missing_charset,already_in_channel",
"response_metadata": map[string]interface{}{"warnings": []string{"missing_charset", "already_in_channel"}}},
} {
t.Run(name, func(t *testing.T) {
server := slackStub(t, "/conversations.join", reply)
require.NoError(t, runJoin("C123", &joinOptions{}, client.NewWithConfig(server.URL, "test-token", nil)))
})
}
}

func TestRunJoin_Error(t *testing.T) {
server := slackStub(t, "/conversations.join", map[string]interface{}{"ok": false, "error": "is_archived"})
err := runJoin("C123", &joinOptions{}, client.NewWithConfig(server.URL, "test-token", nil))
require.Error(t, err)
assert.Contains(t, err.Error(), "is_archived")
}

func TestRunLeave(t *testing.T) {
for name, reply := range map[string]map[string]interface{}{
"leaves": {"ok": true},
"not member": {"ok": true, "not_in_channel": true},
} {
t.Run(name, func(t *testing.T) {
server := slackStub(t, "/conversations.leave", reply)
require.NoError(t, runLeave("C123", &leaveOptions{}, client.NewWithConfig(server.URL, "test-token", nil)))
})
}
}

func TestRunLeave_Error(t *testing.T) {
server := slackStub(t, "/conversations.leave", map[string]interface{}{"ok": false, "error": "cant_leave_general"})
err := runLeave("C123", &leaveOptions{}, client.NewWithConfig(server.URL, "test-token", nil))
require.Error(t, err)
assert.Contains(t, err.Error(), "cant_leave_general")
}
57 changes: 57 additions & 0 deletions internal/cmd/channels/join.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package channels

import (
"fmt"

"github.com/spf13/cobra"

"github.com/open-cli-collective/slack-chat-api/internal/client"
"github.com/open-cli-collective/slack-chat-api/internal/output"
)

type joinOptions struct{}

func newJoinCmd() *cobra.Command {
opts := &joinOptions{}

return &cobra.Command{
Use: "join <channel>",
Short: "Join a public channel as the token's user or bot",
Long: `Join a public channel as the identity of the token in use.

With --as-user this joins as you, which is what lets you invite others to a
channel you are not yet in. Joining is idempotent.`,
Example: ` slck channels join general --as-user
slck channels join C1234567890 --as-user`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runJoin(args[0], opts, nil)
},
}
}

func runJoin(channel string, opts *joinOptions, c *client.Client) error {
if c == nil {
var err error
c, err = client.New()
if err != nil {
return err
}
}

channelID, err := c.ResolveChannel(channel)
if err != nil {
return err
}

alreadyMember, err := c.JoinChannel(channelID)
if err != nil {
return client.WrapError(fmt.Sprintf("join channel %s", channel), err)
}
if alreadyMember {
output.Printf("Already in channel %s\n", channel)
return nil
}
output.Printf("Joined channel %s\n", channel)
return nil
}
54 changes: 54 additions & 0 deletions internal/cmd/channels/leave.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package channels

import (
"fmt"

"github.com/spf13/cobra"

"github.com/open-cli-collective/slack-chat-api/internal/client"
"github.com/open-cli-collective/slack-chat-api/internal/output"
)

type leaveOptions struct{}

func newLeaveCmd() *cobra.Command {
opts := &leaveOptions{}

return &cobra.Command{
Use: "leave <channel>",
Short: "Leave a channel as the token's user or bot",
Long: `Leave a channel as the identity of the token in use. Leaving is
idempotent, and a public channel can be joined again with "slck channels join".`,
Example: ` slck channels leave general --as-user`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runLeave(args[0], opts, nil)
},
}
}

func runLeave(channel string, opts *leaveOptions, c *client.Client) error {
if c == nil {
var err error
c, err = client.New()
if err != nil {
return err
}
}

channelID, err := c.ResolveChannel(channel)
if err != nil {
return err
}

notMember, err := c.LeaveChannel(channelID)
if err != nil {
return client.WrapError(fmt.Sprintf("leave channel %s", channel), err)
}
if notMember {
output.Printf("Not in channel %s\n", channel)
return nil
}
output.Printf("Left channel %s\n", channel)
return nil
}
Loading