Skip to content

Commit 3b6d56c

Browse files
isaacrowntreeclaude
andcommitted
feat: add DM support to message send, improve scope errors, show capabilities in auth status
- Add ResolveDM() to open DM conversations via conversations.open - Auto-detect user vs channel targets in message send (supports @user, username, user ID) - Add context-aware FormatSendError() with specific scope hints for DMs vs channels - Add case-insensitive user lookup in resolver - Add im:write and chat:write to user scopes in app manifest - Add capabilities section to auth status output - Update help text, examples, and docs for DM sending Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d84d977 commit 3b6d56c

6 files changed

Lines changed: 121 additions & 10 deletions

File tree

docs/src/content/docs/commands.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,24 +224,33 @@ Quick actions:
224224
Edit: slackbuzz message edit #general <ts> "new text"
225225
```
226226

227-
### `message send <channel> <text>`
227+
### `message send <channel|user> <text>`
228228

229-
Send a message to a channel or DM.
229+
Send a message to a channel or DM. The first argument accepts a `#channel-name`, channel ID, `@username`, username, or user ID. When the target looks like a user, the CLI automatically opens a DM conversation via `conversations.open`.
230+
231+
**DM sending requires `im:write` and `chat:write` user token scopes.** Apps created with `slackbuzz app create` include these scopes by default. For existing apps, add the scopes at api.slack.com/apps > OAuth & Permissions.
230232

231233
```sh
232234
# Send to a channel
233235
slackbuzz message send #general "Hello from the terminal!"
234236

235-
# Send a DM
237+
# Send a DM by @username
236238
slackbuzz message send @sarah "Quick question about the API"
237239

240+
# Send a DM by username (no @ needed)
241+
slackbuzz message send herman "Hey, got a minute?"
242+
243+
# Send a DM by user ID
244+
slackbuzz message send U02P3QC5H24 "Direct message by ID"
245+
238246
# Reply in a thread
239247
slackbuzz message send #general "Fixed!" --thread-ts 1706000000.000000
240248
```
241249

242250
| Flag | Description |
243251
|------|-------------|
244252
| `--thread-ts TIMESTAMP` | Send as a thread reply |
253+
| `--as-bot` | Send as the bot instead of your user account |
245254

246255
### `message edit <channel> <timestamp> [new-text]`
247256

internal/api/errors.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import (
44
"errors"
55
"fmt"
6+
"strings"
67
)
78

89
// AuthExpiredError indicates the token has been revoked or expired (401 response).
@@ -60,3 +61,26 @@ func FormatError(err error) string {
6061
}
6162
return SlackErrorMessage(err.Error())
6263
}
64+
65+
// FormatSendError produces a context-aware error message for message send failures.
66+
// The target is the original channel/user argument from the command.
67+
func FormatSendError(err error, target string) string {
68+
if err == nil {
69+
return ""
70+
}
71+
errStr := err.Error()
72+
73+
if errStr == "missing_scope" {
74+
isDM := strings.HasPrefix(target, "@") || isUserID(target) ||
75+
(!strings.HasPrefix(target, "#") && !isChannelID(target))
76+
if isDM {
77+
return "Missing Slack scope for DMs. Ensure your user token has: im:write, chat:write\n" +
78+
" Check scopes at api.slack.com/apps > OAuth & Permissions"
79+
}
80+
return "Missing scope for posting. Ensure your token has: chat:write\n" +
81+
" Check scopes at api.slack.com/apps > OAuth & Permissions"
82+
}
83+
84+
return SlackErrorMessage(errStr)
85+
}
86+

internal/api/resolve.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,39 @@ func (r *Resolver) loadChannels() error {
129129
return nil
130130
}
131131

132+
// ResolveDM takes a username, @username, or user ID and returns a DM channel ID.
133+
// It resolves the user and opens (or retrieves) a DM conversation via conversations.open.
134+
func (r *Resolver) ResolveDM(nameOrID string) (string, error) {
135+
userID, err := r.ResolveUser(nameOrID)
136+
if err != nil {
137+
return "", err
138+
}
139+
140+
ch, _, _, err := r.client.OpenConversation(&slack.OpenConversationParameters{
141+
Users: []string{userID},
142+
})
143+
if err != nil {
144+
return "", fmt.Errorf("failed to open DM with %q: %w", nameOrID, err)
145+
}
146+
return ch.ID, nil
147+
}
148+
149+
// LooksLikeUser returns true if the target looks like a user reference
150+
// (starts with @, starts with U/W, or is not #-prefixed and not a channel ID).
151+
func LooksLikeUser(target string) bool {
152+
if strings.HasPrefix(target, "@") {
153+
return true
154+
}
155+
if isUserID(target) {
156+
return true
157+
}
158+
if strings.HasPrefix(target, "#") || isChannelID(target) {
159+
return false
160+
}
161+
// Bare name — assume user if it doesn't look like a channel ID
162+
return true
163+
}
164+
132165
func (r *Resolver) loadUsers() error {
133166
users, err := r.client.GetUsers()
134167
if err != nil {
@@ -140,8 +173,16 @@ func (r *Resolver) loadUsers() error {
140173
for _, u := range users {
141174
if !u.Deleted {
142175
r.users[u.Name] = u.ID
176+
lower := strings.ToLower(u.Name)
177+
if lower != u.Name {
178+
r.users[lower] = u.ID
179+
}
143180
if u.Profile.DisplayName != "" {
144181
r.users[u.Profile.DisplayName] = u.ID
182+
lowerDisplay := strings.ToLower(u.Profile.DisplayName)
183+
if lowerDisplay != u.Profile.DisplayName {
184+
r.users[lowerDisplay] = u.ID
185+
}
145186
}
146187
}
147188
}

pkg/cmd/app/create.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ var appManifest = map[string]interface{}{
8585
"users:read",
8686
},
8787
"user": []string{
88+
"chat:write",
89+
"im:write",
8890
"search:read",
8991
"stars:read",
9092
"stars:write",

pkg/cmd/auth/status.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,25 @@ func statusRun(f *cmdutil.Factory) error {
9090
}
9191
fmt.Fprintln(ios.Out)
9292

93+
// Capabilities section
94+
fmt.Fprintln(ios.Out)
95+
fmt.Fprintf(ios.Out, " %s\n", cs.Bold("Capabilities:"))
96+
97+
botValid := botErr == nil && botToken != ""
98+
userValid := userErr == nil && userToken != ""
99+
100+
if botValid {
101+
fmt.Fprintf(ios.Out, " Bot: read channels, post messages, reactions, list users\n")
102+
}
103+
if userValid {
104+
fmt.Fprintf(ios.Out, " User: search, DMs, stars, profile, send messages\n")
105+
}
106+
if !userValid {
107+
fmt.Fprintf(ios.Out, " %s User token not set — search, DMs, and stars unavailable\n", cs.Yellow("!"))
108+
fmt.Fprintf(ios.Out, " %s Add with: slackbuzz auth login\n", cs.Gray(" "))
109+
} else {
110+
fmt.Fprintf(ios.Out, " %s DM sending requires im:write and chat:write user scopes\n", cs.Gray("Hint:"))
111+
}
112+
93113
return nil
94114
}

pkg/cmd/message/send.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,25 @@ func NewCmdSend(f *cmdutil.Factory) *cobra.Command {
2626
opts := &sendOptions{factory: f}
2727

2828
cmd := &cobra.Command{
29-
Use: "send <channel> [text]",
30-
Short: "Send a message to a channel",
29+
Use: "send <channel|user> [text]",
30+
Short: "Send a message to a channel or DM",
3131
Long: `Post a message to a Slack channel, DM, or thread.
3232
33-
The channel argument accepts #channel-name or a channel ID.
33+
The first argument accepts a #channel-name, channel ID, @username, or user ID.
34+
To send a DM, use a username, @username, or user ID as the channel argument.
3435
If text is omitted, reads from stdin (for piping).`,
35-
Example: ` # Send a message
36+
Example: ` # Send a message to a channel
3637
slackbuzz message send #general "Hello, world!"
3738
39+
# Send a DM by @username
40+
slackbuzz message send @sarah "Quick question about the API"
41+
42+
# Send a DM by username (no @ needed)
43+
slackbuzz message send herman "Hey, got a minute?"
44+
45+
# Send a DM by user ID
46+
slackbuzz message send U02P3QC5H24 "Direct message by ID"
47+
3848
# Send to a thread
3949
slackbuzz message send #general "Reply here" --thread-ts 1234567890.123456
4050
@@ -78,8 +88,13 @@ func sendRun(opts *sendOptions) error {
7888

7989
resolver := api.NewResolver(client.Slack)
8090

81-
// Resolve channel name to ID
82-
channelID, err := resolver.ResolveChannel(opts.channel)
91+
// Resolve target to a channel ID — auto-detect DMs vs channels
92+
var channelID string
93+
if api.LooksLikeUser(opts.channel) {
94+
channelID, err = resolver.ResolveDM(opts.channel)
95+
} else {
96+
channelID, err = resolver.ResolveChannel(opts.channel)
97+
}
8398
if err != nil {
8499
return err
85100
}
@@ -119,7 +134,7 @@ func sendRun(opts *sendOptions) error {
119134
// Post the message
120135
respChannel, respTS, err := client.Slack.PostMessage(channelID, msgOpts...)
121136
if err != nil {
122-
return fmt.Errorf("%s", api.FormatError(err))
137+
return fmt.Errorf("%s", api.FormatSendError(err, opts.channel))
123138
}
124139

125140
if opts.json.WantsJSON() {

0 commit comments

Comments
 (0)