diff --git a/README.md b/README.md index 474af6a..0a52878 100644 --- a/README.md +++ b/README.md @@ -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 @@ -587,6 +591,8 @@ slck channels invite C1234567890 U1111111111 U2222222222 | `set-topic ` | | Set channel topic | | `set-purpose ` | | Set channel purpose | | `invite ...` | | Invite users to channel | +| `join ` | | Join a public channel as the token's identity (idempotent) | +| `leave ` | | Leave a channel as the token's identity (idempotent) | ### Users diff --git a/integration-tests.md b/integration-tests.md index b7ffe54..adbdc1d 100644 --- a/integration-tests.md +++ b/integration-tests.md @@ -456,6 +456,17 @@ Using **NEW_CHANNEL_ID** from step 5.1: | 3 | `slck channels unarchive ` | "Unarchived channel" | | 4 | `slck channels unarchive ` | "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 --as-user` | "Joined channel" | +| 2 | `slck channels join --as-user` | "Already in channel" (idempotent, exit 0) | +| 3 | `slck channels leave --as-user` | "Left channel" | +| 4 | `slck channels leave --as-user` | "Not in channel" (idempotent, exit 0) | + ### 5.4 Cleanup: Archive Test Channels | Step | Command | Expected | diff --git a/internal/client/client.go b/internal/client/client.go index 245f141..64cec63 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "os" + "slices" "strconv" "time" @@ -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 +} + +// 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{}{ diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 5beefcc..3b8a4ba 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -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) + } + }) + } +} diff --git a/internal/cmd/channels/channels.go b/internal/cmd/channels/channels.go index 8869ca2..d2db8e5 100644 --- a/internal/cmd/channels/channels.go +++ b/internal/cmd/channels/channels.go @@ -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 } diff --git a/internal/cmd/channels/channels_test.go b/internal/cmd/channels/channels_test.go index d32b8e7..bc66a50 100644 --- a/internal/cmd/channels/channels_test.go +++ b/internal/cmd/channels/channels_test.go @@ -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") +} diff --git a/internal/cmd/channels/join.go b/internal/cmd/channels/join.go new file mode 100644 index 0000000..9eb8f43 --- /dev/null +++ b/internal/cmd/channels/join.go @@ -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 ", + 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 +} diff --git a/internal/cmd/channels/leave.go b/internal/cmd/channels/leave.go new file mode 100644 index 0000000..224f371 --- /dev/null +++ b/internal/cmd/channels/leave.go @@ -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 ", + 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 +}