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
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ or `op read ... | slck set-credential --key bot_token --stdin`.
"mpim:read",
"mpim:write",
"pins:read",
"pins:write",
"reactions:read",
"reactions:write",
"reminders:read",
Expand Down Expand Up @@ -345,6 +346,7 @@ or `op read ... | slck set-credential --key bot_token --stdin`.
"mpim:read",
"mpim:write",
"pins:read",
"pins:write",
"reactions:read",
"reactions:write",
"reminders:read",
Expand Down Expand Up @@ -452,7 +454,8 @@ The **extended manifest** (see the collapsible section above) adds these capabil
| `usergroups:read` | Resolve `@subteam` / user-group mentions |
| `app_mentions:read` | Receive @-mention events (needed for event subscriptions) |
| `reactions:read` | List reactions on a message |
| Other `*:read` scopes (`bookmarks`, `pins`, `reminders`, `links`, `lists`, `calls`, `dnd`, `metadata.message`, `remote_files`) | Read-only access to those domains; mostly forward-compat for commands that may use them |
| `pins:read` / `pins:write` | List, add and remove pinned messages: `slck pins list/add/remove` |
| Other `*:read` scopes (`bookmarks`, `reminders`, `links`, `lists`, `calls`, `dnd`, `metadata.message`, `remote_files`) | Read-only access to those domains; mostly forward-compat for commands that may use them |

### Token Types

Expand Down Expand Up @@ -706,6 +709,36 @@ slck messages unreact C1234567890 1234567890.123456 thumbsup
reactions: :+1: 2 (alice, bob), :eyes: 1 (carol)
```

### Pins

```bash
# Pin a message (the timestamp can also be p-prefixed or the message's Slack URL)
slck pins add C1234567890 1234567890.123456

# List what is pinned in a channel, most recently pinned first
slck pins list C1234567890

# Unpin a message
slck pins remove C1234567890 1234567890.123456
```

`add` and `remove` need the `pins:write` scope and `list` needs `pins:read`, on the bot token, or on the user token with `--as-user`. Neither scope is in the default manifest; the extended manifest has both. Adding a pin that exists, or removing one that does not, succeeds and says so.

`list` prints one row per pin; `remove` takes the `TS` value. A pinned file has `-` for `TS` and `AUTHOR`.

```text
TS | PINNED | PINNED_BY | AUTHOR | TEXT
1699999000.000200 | 2023-11-14 22:16 | bob | alice | On call this week: @bob
```

#### Pins Command Reference

| Command | Flags | Description |
|---------|-------|-------------|
| `add <channel> <ts>` | | Pin a message (idempotent) |
| `remove <channel> <ts>` | | Unpin a message (idempotent) |
| `list <channel>` | | List pinned items |

### Search

> **Note:** Search requires a user token (`xoxp-*`). See [Token Types](#token-types).
Expand Down Expand Up @@ -1022,6 +1055,7 @@ Commands have convenient aliases:
| `channels` | `ch` |
| `users` | `u` |
| `messages` | `msg`, `m` |
| `pins` | `pin` |
| `search` | `s` |
| `emoji` | `e` |
| `workspace` | `ws`, `team` |
Expand Down
6 changes: 4 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ Collective standards and automation remain canonical in their own repositories.
## Project Overview

slack-chat-api builds the `slck` command-line interface for Slack. It supports
channel management, user lookup, messaging, reactions, message history, and
workspace information.
channel management, user lookup, messaging, reactions, pins, message history,
and workspace information.

## Quick Commands

Expand Down Expand Up @@ -44,6 +44,8 @@ slack-chat-api/
- New commands live under the appropriate `internal/cmd/<resource>/` package.
- Register new commands from the resource root command.
- Add tests around the command runner with a mock or injectable client.
- Call `cmdutil.Quiet(cmd)` first in `RunE`, so a runtime error prints once,
with its hint and without the usage text. Argument errors still print usage.

## Output Contract

Expand Down
16 changes: 15 additions & 1 deletion integration-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ In **OAuth & Permissions**, add these **Bot Token Scopes**:
| `team:read` | Get workspace info | Part 2 |
| `chat:write` | Send, update, delete messages | Part 3 |
| `reactions:write` | Add/remove reactions | Part 3 |
| `pins:read` | List pinned messages | Part 3 |
| `pins:write` | Pin and unpin messages | Part 3 |
| `channels:manage` | Create, archive, set topic/purpose, invite | Parts 4 & 5 |
| `groups:write` | Topic/purpose/invite for private channels | Part 4 |
| `emoji:read` | List custom workspace emoji | Part 7 |
Expand Down Expand Up @@ -164,7 +166,7 @@ These tests don't modify anything. Safe to run anytime.

## Part 3: Messaging Tests

**Scopes required:** `chat:write`, `reactions:write`
**Scopes required:** `chat:write`, `reactions:write`, `pins:read`, `pins:write`

These tests create messages, then clean them up at the end.

Expand Down Expand Up @@ -197,6 +199,18 @@ Using **TS₁** from step 3.1:
| 5 | `slck messages unreact $TEST_CHANNEL_ID <TS₁> thumbsup` | "Removed :thumbsup: reaction" |
| 6 | `slck messages unreact $TEST_CHANNEL_ID <TS₁> heart` | "Removed :heart: reaction" |

### 3.3a Pins

Using **TS₁** from step 3.1:

| Step | Command | Expected |
|------|---------|----------|
| 1 | `slck pins add $TEST_CHANNEL_ID <TS₁>` | "Pinned message <TS₁> in $TEST_CHANNEL_ID" |
| 2 | `slck pins add $TEST_CHANNEL_ID <TS₁>` | "Message <TS₁> is already pinned in $TEST_CHANNEL_ID" (idempotent, exit 0) |
| 3 | `slck pins list $TEST_CHANNEL_ID` | `TS \| PINNED \| PINNED_BY \| AUTHOR \| TEXT` header, then a row whose TS is TS₁ |
| 4 | `slck pins remove $TEST_CHANNEL_ID <TS₁>` | "Unpinned message <TS₁> in $TEST_CHANNEL_ID" |
| 5 | `slck pins remove $TEST_CHANNEL_ID <TS₁>` | "Message <TS₁> is not pinned in $TEST_CHANNEL_ID" (idempotent, exit 0) |

### 3.4 Threading

Using **TS₁** from step 3.1:
Expand Down
12 changes: 12 additions & 0 deletions internal/client/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,15 @@ func WrapError(operation string, err error) error {

return fmt.Errorf("%s: %w", operation, err)
}

// WrapScopeError is WrapError for a call that needs one known OAuth scope:
// a missing_scope error names that scope and where to grant it.
func WrapScopeError(operation, scope string, err error) error {
if !IsSlackError(err, "missing_scope") {
Comment thread
monit-reviewer marked this conversation as resolved.
Resolved
return WrapError(operation, err)
}
hint := fmt.Sprintf("The token is missing the %s scope. Add %s to the app's Bot Token Scopes "+
"(or User Token Scopes for --as-user) at api.slack.com/apps, then reinstall the app to the workspace.",
scope, scope)
return fmt.Errorf("%s: %w\nHint: %s", operation, err, hint)
}
15 changes: 15 additions & 0 deletions internal/client/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,18 @@ func TestWrapError_OperationContext(t *testing.T) {
assert.Contains(t, errStr, "archive channel C123")
assert.Contains(t, errStr, "already_archived")
}

func TestWrapScopeError(t *testing.T) {
assert.NoError(t, WrapScopeError("pin message", "pins:write", nil))

err := WrapScopeError("pin message", "pins:write", fmt.Errorf("slack API error: missing_scope"))
require.Error(t, err)
assert.Equal(t, "pin message: slack API error: missing_scope\n"+
"Hint: The token is missing the pins:write scope. Add pins:write to the app's Bot Token Scopes "+
"(or User Token Scopes for --as-user) at api.slack.com/apps, then reinstall the app to the workspace.", err.Error())

err = WrapScopeError("pin message", "pins:write", fmt.Errorf("slack API error: channel_not_found"))
require.Error(t, err)
assert.Contains(t, err.Error(), "Verify the channel ID is correct")
assert.NotContains(t, err.Error(), "pins:write")
}
63 changes: 63 additions & 0 deletions internal/client/pins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package client

import (
"encoding/json"
"net/url"
)

// PinnedItem is one entry of pins.list. Type is "message" for a pinned
// message; Slack can also return "file" and "file_comment" items, which carry
// File instead of Message.
type PinnedItem struct {
Type string `json:"type"`
Channel string `json:"channel"`
Created int64 `json:"created"`
CreatedBy string `json:"created_by"`
Message *Message `json:"message,omitempty"`
File *File `json:"file,omitempty"`
}

// AddPin pins a message to a channel. It reports whether the message was
// already pinned, which Slack signals with an already_pinned error.
func (c *Client) AddPin(channel, timestamp string) (alreadyPinned bool, err error) {
_, err = c.post("pins.add", map[string]interface{}{
"channel": channel,
"timestamp": timestamp,
})
if IsSlackError(err, "already_pinned") {
return true, nil
}
return false, err
}

// RemovePin unpins a message from a channel. It reports whether the message
// was not pinned, which Slack signals with a no_pin or not_pinned error.
func (c *Client) RemovePin(channel, timestamp string) (notPinned bool, err error) {
_, err = c.post("pins.remove", map[string]interface{}{
"channel": channel,
"timestamp": timestamp,
})
if IsSlackError(err, "no_pin") || IsSlackError(err, "not_pinned") {
return true, nil
}
return false, err
}

// ListPins returns the items pinned to a channel.
func (c *Client) ListPins(channel string) ([]PinnedItem, error) {
params := url.Values{}
params.Set("channel", channel)

body, err := c.get("pins.list", params)
if err != nil {
return nil, err
}

var result struct {
Items []PinnedItem `json:"items"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result.Items, nil
}
21 changes: 7 additions & 14 deletions internal/cmd/apps/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"golang.org/x/term"

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

Expand Down Expand Up @@ -66,14 +67,6 @@ func wrap(operation string, err error) error {
return fmt.Errorf("%s: %w", operation, err)
}

// quiet stops cobra from printing a runtime error and the usage text: once
// the arguments parsed, the root command prints the error alone, and a
// silent exitError (diff's "differs") prints nothing more.
func quiet(cmd *cobra.Command) {
cmd.SilenceUsage = true
cmd.SilenceErrors = true
}

// NewCmd builds `slck apps`.
func NewCmd() *cobra.Command {
cmd := &cobra.Command{
Expand Down Expand Up @@ -111,7 +104,7 @@ it is printed as JSON whatever --output says.`,
slck apps get A0123456789 --to-file app-manifest.json`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
quiet(cmd)
cmdutil.Quiet(cmd)
return runGet(newSession(), args[0], toFile)
},
}
Expand Down Expand Up @@ -155,7 +148,7 @@ and non-zero, listing each problem, when it is not.`,
slck apps validate --file app-manifest.yaml --app A0123456789`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
quiet(cmd)
cmdutil.Quiet(cmd)
return runValidate(newSession(), file, appID)
},
}
Expand Down Expand Up @@ -193,7 +186,7 @@ could not be made.`,
return cannotCompare(cobra.ExactArgs(1)(cmd, args))
},
RunE: func(cmd *cobra.Command, args []string) error {
quiet(cmd)
cmdutil.Quiet(cmd)
return runDiff(newSession(), args[0], file)
},
}
Expand Down Expand Up @@ -244,7 +237,7 @@ Basic Information page of the app still shows them.`,
Example: ` slck apps create --file app-manifest.json --credentials-file app-credentials.json`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
quiet(cmd)
cmdutil.Quiet(cmd)
return runCreate(newSession(), file, credentialsFile)
},
}
Expand Down Expand Up @@ -312,7 +305,7 @@ is reinstalled to the workspace, and the command says so.`,
slck apps update A0123456789 --file app-manifest.json`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
quiet(cmd)
cmdutil.Quiet(cmd)
return runUpdate(newSession(), args[0], file)
},
}
Expand Down Expand Up @@ -358,7 +351,7 @@ when stdin is not a terminal.`,
Example: ` slck apps delete A0123456789`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
quiet(cmd)
cmdutil.Quiet(cmd)
opts.stdin = os.Stdin
opts.terminal = term.IsTerminal(int(os.Stdin.Fd()))
return runDelete(newSession(), args[0], opts)
Expand Down
12 changes: 12 additions & 0 deletions internal/cmd/cmdutil/cmdutil.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Package cmdutil holds cobra helpers shared by the command packages.
package cmdutil

import "github.com/spf13/cobra"

// Quiet stops cobra from printing a runtime error and the usage text once the
// arguments parsed. The root command then prints the error alone, or nothing
// for an error that reports itself as silent. Call it first in RunE.
func Quiet(cmd *cobra.Command) {
cmd.SilenceUsage = true
cmd.SilenceErrors = true
}
60 changes: 60 additions & 0 deletions internal/cmd/pins/add.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package pins

import (
"github.com/spf13/cobra"

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

func newAddCmd() *cobra.Command {
return &cobra.Command{
Use: "add <channel> <timestamp>",
Short: "Pin a message to a channel",
Long: `Pin a message to a channel.

The timestamp is the message's ts (1234567890.123456), its p-prefixed form,
or the message's Slack URL. Pinning a message that is already pinned
succeeds and says so. Requires the pins:write scope.`,
Example: ` slck pins add C1234567890 1234567890.123456
slck pins add general 1234567890.123456 --as-user`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cmdutil.Quiet(cmd)
return runAdd(args[0], args[1], nil)
},
}
}

func runAdd(channel, timestamp string, c *client.Client) error {
if err := validate.Timestamp(timestamp); err != nil {
return err
}
timestamp = validate.NormalizeTimestamp(timestamp)

if c == nil {
var err error
c, err = client.New()
if err != nil {
return err
}
}

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

alreadyPinned, err := c.AddPin(channelID, timestamp)
if err != nil {
return client.WrapScopeError("pin message", writeScope, err)
}
if alreadyPinned {
output.Printf("Message %s is already pinned in %s\n", timestamp, channel)
return nil
}
output.Printf("Pinned message %s in %s\n", timestamp, channel)
return nil
}
Loading
Loading