Skip to content

Commit 81125da

Browse files
Copilotalexec
andauthored
Add thread reply support with optional thread-ts parameter (#10)
* Initial plan * Add reply-message functionality for threading support Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Fix code formatting for consistency Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Refactor to use optional timestamp parameter on send-message Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Rename parameter to [thread-ts] and clarify it's for replying to threads Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Print thread-ts after sending messages for thread continuity Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Only print thread-ts for new messages, not thread replies Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
1 parent 7dd1236 commit 81125da

4 files changed

Lines changed: 120 additions & 20 deletions

File tree

README.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Add this to your prompt (e.g. `AGENTS.md`):
3636

3737
```markdown
3838
- You can send messages to a Slack user by using the `slack send-message <channel|email> "<message>"` command.
39+
- You can reply to a message in a thread by adding the thread timestamp as a third parameter: `slack send-message <channel|email> "<message>" <thread-ts>`.
3940
- The message supports Markdown formatting which will be automatically converted to Slack's Mrkdwn format.
4041
- For AI assistants supporting MCP (Model Context Protocol), you can use `slack mcp-server` to enable tool-based Slack integration.
4142
```
@@ -46,16 +47,27 @@ Add this to your prompt (e.g. `AGENTS.md`):
4647

4748
```bash
4849
Usage:
49-
slack configure - configure Slack token (reads from stdin)
50-
slack send-message <channel|email> <message> - send a message to a user
51-
slack mcp-server - start MCP server (Model Context Protocol)
50+
slack configure - configure Slack token (reads from stdin)
51+
slack send-message <channel|email> <message> [thread-ts] - send a message (optionally reply to a thread)
52+
slack mcp-server - start MCP server (Model Context Protocol)
5253
```
5354

54-
**Example:**
55+
**Examples:**
5556
```bash
57+
# Send a message (prints thread-ts for starting a thread)
5658
slack send-message alex_collins@intuit.com "I love this tool! It makes Slack integration so easy."
59+
# Output:
60+
# Message sent to alex_collins@intuit.com (U12345678)
61+
# thread-ts: 1234567890.123456
62+
63+
# Reply to a message in a thread (use the thread-ts from the previous message)
64+
slack send-message alex_collins@intuit.com "Thanks for the feedback!" "1234567890.123456"
65+
# Output:
66+
# Reply sent to alex_collins@intuit.com (U12345678) in thread 1234567890.123456
5767
```
5868

69+
The `thread-ts` is only printed when sending a new message (not when replying to a thread), allowing you to use it to start a threaded conversation.
70+
5971
### MCP Server Mode
6072

6173
The MCP (Model Context Protocol) server allows AI assistants and other tools to interact with Slack through a standardized JSON-RPC protocol over stdio. This enables seamless integration with AI coding assistants and other automation tools.
@@ -79,9 +91,11 @@ The MCP (Model Context Protocol) server allows AI assistants and other tools to
7991
}
8092
```
8193

82-
The server exposes a `send_message` tool that accepts:
94+
The server exposes the `send_message` tool with the following parameters:
8395
- `identifier` - Slack channel ID (e.g., 'C1234567890') or user email address (e.g., 'user@example.com')
8496
- `message` - The message to send (supports Markdown formatting)
97+
- `thread_ts` - Optional: The thread timestamp of the parent message to reply to (e.g., '1234567890.123456'). When provided, the message will be sent as a threaded reply.
8598

8699
**Example usage from an AI assistant:**
87100
> "Slack alex_collins@intuit.com to say how much you like this tool."
101+
> "Reply to that Slack message with a thumbs up emoji."

main.go

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ func main() {
2828
w := flag.CommandLine.Output()
2929
fmt.Fprintf(w, "Usage:")
3030
fmt.Fprintln(w)
31-
fmt.Fprintln(w, " slack configure - configure Slack token (reads from stdin)")
32-
fmt.Fprintln(w, " slack send-message <channel|email> <message> - send a message to a user")
33-
fmt.Fprintln(w, " slack mcp-server - start MCP server (Model Context Protocol)")
31+
fmt.Fprintln(w, " slack configure - configure Slack token (reads from stdin)")
32+
fmt.Fprintln(w, " slack send-message <channel|email> <message> [thread-ts] - send a message (optionally reply to a thread)")
33+
fmt.Fprintln(w, " slack mcp-server - start MCP server (Model Context Protocol)")
3434
fmt.Fprintln(w)
3535
}
3636
flag.Parse()
@@ -54,7 +54,7 @@ func run(ctx context.Context, args []string) error {
5454
return runMCPServer(ctx)
5555
case "send-message":
5656
if len(args) < 3 {
57-
return fmt.Errorf("usage: slack send-message <channel|email> <message>")
57+
return fmt.Errorf("usage: slack send-message <channel|email> <message> [thread-ts]")
5858
}
5959

6060
token := getToken()
@@ -66,7 +66,14 @@ func run(ctx context.Context, args []string) error {
6666
http.DefaultTransport.(*http.Transport).ForceAttemptHTTP2 = false
6767
api := slack.New(token)
6868

69-
return sendMessage(ctx, api, args[1], args[2])
69+
// Check if optional thread-ts parameter is provided for replying to a thread
70+
var timestamp string
71+
if len(args) >= 4 {
72+
timestamp = args[3]
73+
}
74+
75+
_, err := sendMessage(ctx, api, args[1], args[2], timestamp)
76+
return err
7077
default:
7178
return fmt.Errorf("unknown sub-command: %s", args[0])
7279
}
@@ -86,12 +93,12 @@ func getToken() string {
8693
return ""
8794
}
8895

89-
func sendMessage(ctx context.Context, api *slack.Client, identifier, body string) error {
96+
func sendMessage(ctx context.Context, api *slack.Client, identifier, body, timestamp string) (string, error) {
9097
var channel string
9198
if strings.Contains(identifier, "@") {
9299
user, err := api.GetUserByEmailContext(ctx, identifier)
93100
if err != nil {
94-
return fmt.Errorf("failed to lookup user: %w", err)
101+
return "", fmt.Errorf("failed to lookup user: %w", err)
95102
}
96103
channel = user.ID
97104
} else {
@@ -101,12 +108,27 @@ func sendMessage(ctx context.Context, api *slack.Client, identifier, body string
101108
// Convert Markdown to Mrkdwn format
102109
mrkdwnBody := convertMarkdownToMrkdwn(body)
103110

104-
if _, _, err := api.PostMessageContext(ctx, channel, slack.MsgOptionText(mrkdwnBody, false)); err != nil {
105-
return fmt.Errorf("failed to send message: %w", err)
111+
// Build message options
112+
options := []slack.MsgOption{slack.MsgOptionText(mrkdwnBody, false)}
113+
114+
// If timestamp is provided, add it to create a threaded reply
115+
if timestamp != "" {
116+
options = append(options, slack.MsgOptionTS(timestamp))
106117
}
107118

108-
fmt.Printf("Message sent to %s (%s)\n", identifier, channel)
109-
return nil
119+
_, respTimestamp, err := api.PostMessageContext(ctx, channel, options...)
120+
if err != nil {
121+
return "", fmt.Errorf("failed to send message: %w", err)
122+
}
123+
124+
if timestamp != "" {
125+
fmt.Printf("Reply sent to %s (%s) in thread %s\n", identifier, channel, timestamp)
126+
} else {
127+
fmt.Printf("Message sent to %s (%s)\n", identifier, channel)
128+
fmt.Printf("thread-ts: %s\n", respTimestamp)
129+
}
130+
131+
return respTimestamp, nil
110132
}
111133

112134
func configureToken(ctx context.Context) error {

main_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,58 @@ func TestConvertMarkdownToMrkdwn_Integration(t *testing.T) {
6969
t.Errorf("Expected '*bold*', got '%s'", result)
7070
}
7171
}
72+
73+
func TestRun_SendMessageMissingArgs(t *testing.T) {
74+
// Set SLACK_TOKEN env var to get past token check
75+
oldToken := os.Getenv("SLACK_TOKEN")
76+
os.Setenv("SLACK_TOKEN", "test-token")
77+
defer func() {
78+
if oldToken == "" {
79+
os.Unsetenv("SLACK_TOKEN")
80+
} else {
81+
os.Setenv("SLACK_TOKEN", oldToken)
82+
}
83+
}()
84+
85+
ctx := context.Background()
86+
87+
// Test with no arguments
88+
err := run(ctx, []string{"send-message"})
89+
if err == nil {
90+
t.Error("Expected error for missing arguments, got nil")
91+
}
92+
if !strings.Contains(err.Error(), "usage:") {
93+
t.Errorf("Expected usage error, got: %v", err)
94+
}
95+
96+
// Test with only channel
97+
err = run(ctx, []string{"send-message", "C1234567890"})
98+
if err == nil {
99+
t.Error("Expected error for missing arguments, got nil")
100+
}
101+
if !strings.Contains(err.Error(), "usage:") {
102+
t.Errorf("Expected usage error, got: %v", err)
103+
}
104+
}
105+
106+
func TestRun_SendMessageMissingToken(t *testing.T) {
107+
// Ensure SLACK_TOKEN env var is not set
108+
oldToken := os.Getenv("SLACK_TOKEN")
109+
os.Unsetenv("SLACK_TOKEN")
110+
defer func() {
111+
if oldToken != "" {
112+
os.Setenv("SLACK_TOKEN", oldToken)
113+
}
114+
}()
115+
116+
ctx := context.Background()
117+
err := run(ctx, []string{"send-message", "C1234567890", "test message"})
118+
119+
if err == nil {
120+
t.Error("Expected error for missing token, got nil")
121+
}
122+
123+
if !strings.Contains(err.Error(), "Slack token must be set") {
124+
t.Errorf("Expected 'Slack token must be set' error, got: %v", err)
125+
}
126+
}

mcp.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func runMCPServer(ctx context.Context) error {
3030

3131
// Define the send_message tool
3232
sendMessageTool := mcp.NewTool("send_message",
33-
mcp.WithDescription("Send a message to a Slack channel or user. You can specify either a channel ID or a user's email address. The message supports Markdown formatting which will be automatically converted to Slack's Mrkdwn format."),
33+
mcp.WithDescription("Send a message to a Slack channel or user. You can specify either a channel ID or a user's email address. The message supports Markdown formatting which will be automatically converted to Slack's Mrkdwn format. Optionally provide a thread_ts to reply to a message in a thread."),
3434
mcp.WithString("identifier",
3535
mcp.Required(),
3636
mcp.Description("The Slack channel ID (e.g., 'C1234567890') or user email address (e.g., 'user@example.com')"),
@@ -39,6 +39,9 @@ func runMCPServer(ctx context.Context) error {
3939
mcp.Required(),
4040
mcp.Description("The message to send. Supports Markdown formatting."),
4141
),
42+
mcp.WithString("thread_ts",
43+
mcp.Description("Optional: The thread timestamp of the parent message to reply to (e.g., '1234567890.123456'). When provided, the message will be sent as a threaded reply."),
44+
),
4245
)
4346

4447
// Add the tool handler
@@ -53,13 +56,19 @@ func runMCPServer(ctx context.Context) error {
5356
return mcp.NewToolResultError(fmt.Sprintf("Missing or invalid 'message' argument: %v", err)), nil
5457
}
5558

56-
// Send the message using the existing sendMessage function
57-
err = sendMessage(ctx, api, identifier, message)
59+
// Get optional thread_ts parameter for replying to a thread
60+
timestamp := request.GetString("thread_ts", "")
61+
62+
// Send the message using the sendMessage function
63+
respTimestamp, err := sendMessage(ctx, api, identifier, message, timestamp)
5864
if err != nil {
5965
return mcp.NewToolResultError(fmt.Sprintf("Error: %v", err)), nil
6066
}
6167

62-
return mcp.NewToolResultText(fmt.Sprintf("Message sent successfully to %s", identifier)), nil
68+
if timestamp != "" {
69+
return mcp.NewToolResultText(fmt.Sprintf("Reply sent successfully to %s in thread %s", identifier, timestamp)), nil
70+
}
71+
return mcp.NewToolResultText(fmt.Sprintf("Message sent successfully to %s\nthread-ts: %s", identifier, respTimestamp)), nil
6372
})
6473

6574
// Start the stdio server

0 commit comments

Comments
 (0)