Skip to content

Commit c9d6cfa

Browse files
authored
feat(chat): add Slack integration with channel resolution and tools (#23)
1 parent f140ccd commit c9d6cfa

17 files changed

+1586
-62
lines changed

internal/chat/approval.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ const approvalTimeout = 5 * time.Minute
1111

1212
// gatedTools lists tools that require user approval before execution.
1313
var gatedTools = map[string]bool{
14-
"send_email": true,
15-
"create_event": true,
14+
"send_email": true,
15+
"create_event": true,
16+
"send_slack_message": true,
1617
}
1718

1819
// IsGated returns true if the tool requires user approval.
@@ -133,6 +134,19 @@ func BuildPreview(call ToolCall) map[string]any {
133134
if desc, ok := call.Args["description"].(string); ok {
134135
preview["description"] = desc
135136
}
137+
case "send_slack_message":
138+
if ch, ok := call.Args["channel"].(string); ok {
139+
preview["channel"] = ch
140+
}
141+
if text, ok := call.Args["text"].(string); ok {
142+
if len(text) > 200 {
143+
text = text[:200] + "..."
144+
}
145+
preview["text"] = text
146+
}
147+
if threadTS, ok := call.Args["thread_ts"].(string); ok && threadTS != "" {
148+
preview["thread_ts"] = threadTS
149+
}
136150
}
137151

138152
return preview

internal/chat/approval_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package chat
22

33
import (
4+
"strings"
45
"sync"
56
"testing"
67
"time"
@@ -201,6 +202,31 @@ func TestPendingApproval_WaitConcurrent(t *testing.T) {
201202
wg.Wait()
202203
}
203204

205+
func TestIsGated_SlackMessage(t *testing.T) {
206+
t.Parallel()
207+
208+
tests := []struct {
209+
name string
210+
toolName string
211+
want bool
212+
}{
213+
{"send_slack_message is gated", "send_slack_message", true},
214+
{"list_slack_channels is not gated", "list_slack_channels", false},
215+
{"read_slack_messages is not gated", "read_slack_messages", false},
216+
{"search_slack is not gated", "search_slack", false},
217+
}
218+
219+
for _, tt := range tests {
220+
t.Run(tt.name, func(t *testing.T) {
221+
t.Parallel()
222+
got := IsGated(tt.toolName)
223+
if got != tt.want {
224+
t.Errorf("IsGated(%q) = %v, want %v", tt.toolName, got, tt.want)
225+
}
226+
})
227+
}
228+
}
229+
204230
func TestBuildPreview(t *testing.T) {
205231
t.Parallel()
206232

@@ -292,3 +318,50 @@ func TestBuildPreview(t *testing.T) {
292318
})
293319
}
294320
}
321+
322+
func TestBuildPreview_SlackMessage(t *testing.T) {
323+
t.Parallel()
324+
325+
preview := BuildPreview(ToolCall{
326+
Name: "send_slack_message",
327+
Args: map[string]any{
328+
"channel": "#engineering",
329+
"text": "Hello team!",
330+
"thread_ts": "1234567890.123456",
331+
},
332+
})
333+
334+
if preview["channel"] != "#engineering" {
335+
t.Errorf("channel = %v, want %q", preview["channel"], "#engineering")
336+
}
337+
if preview["text"] != "Hello team!" {
338+
t.Errorf("text = %v, want %q", preview["text"], "Hello team!")
339+
}
340+
if preview["thread_ts"] != "1234567890.123456" {
341+
t.Errorf("thread_ts = %v, want %q", preview["thread_ts"], "1234567890.123456")
342+
}
343+
}
344+
345+
func TestBuildPreview_SlackMessage_LongText(t *testing.T) {
346+
t.Parallel()
347+
348+
longText := strings.Repeat("a", 300)
349+
preview := BuildPreview(ToolCall{
350+
Name: "send_slack_message",
351+
Args: map[string]any{
352+
"channel": "#general",
353+
"text": longText,
354+
},
355+
})
356+
357+
text, ok := preview["text"].(string)
358+
if !ok {
359+
t.Fatal("text is not a string")
360+
}
361+
if len(text) > 203 { // 200 + "..."
362+
t.Errorf("text length = %d, want <= 203", len(text))
363+
}
364+
if !strings.HasSuffix(text, "...") {
365+
t.Error("text should end with '...'")
366+
}
367+
}

internal/chat/chat.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,36 @@ import (
99

1010
browserpkg "github.com/nylas/cli/internal/adapters/browser"
1111
"github.com/nylas/cli/internal/adapters/config"
12+
"github.com/nylas/cli/internal/adapters/keyring"
13+
slackadapter "github.com/nylas/cli/internal/adapters/slack"
1214
"github.com/nylas/cli/internal/cli/common"
15+
"github.com/nylas/cli/internal/ports"
1316
)
1417

18+
// trySlackClient attempts to create a Slack client from stored credentials.
19+
// Returns nil if Slack is not configured (not an error).
20+
func trySlackClient() ports.SlackClient {
21+
token := os.Getenv("SLACK_USER_TOKEN")
22+
if token == "" {
23+
store, err := keyring.NewSecretStore(config.DefaultConfigDir())
24+
if err != nil {
25+
return nil
26+
}
27+
token, err = store.Get("slack_user_token")
28+
if err != nil || token == "" {
29+
return nil
30+
}
31+
}
32+
33+
cfg := slackadapter.DefaultConfig()
34+
cfg.UserToken = token
35+
client, err := slackadapter.NewClient(cfg)
36+
if err != nil {
37+
return nil
38+
}
39+
return client
40+
}
41+
1542
// NewChatCmd creates the chat command.
1643
func NewChatCmd() *cobra.Command {
1744
var (
@@ -73,6 +100,8 @@ and perform actions on your behalf through a web-based chat interface.`,
73100
return err
74101
}
75102

103+
slackClient := trySlackClient()
104+
76105
// Set up conversation storage
77106
chatDir := filepath.Join(config.DefaultConfigDir(), "chat", "conversations")
78107
memory, err := NewMemoryStore(chatDir)
@@ -85,6 +114,9 @@ and perform actions on your behalf through a web-based chat interface.`,
85114

86115
fmt.Printf("Starting Nylas Chat at %s\n", url)
87116
fmt.Printf("Agent: %s\n", agent)
117+
if slackClient != nil {
118+
fmt.Println("Slack: connected")
119+
}
88120
fmt.Println("Press Ctrl+C to stop")
89121
fmt.Println()
90122

@@ -96,7 +128,7 @@ and perform actions on your behalf through a web-based chat interface.`,
96128
}
97129
}
98130

99-
server := NewServer(addr, agent, agents, nylasClient, grantID, memory)
131+
server := NewServer(addr, agent, agents, nylasClient, grantID, memory, slackClient)
100132
return server.Start()
101133
},
102134
}

internal/chat/context.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,19 @@ const (
1515

1616
// ContextBuilder constructs prompts with conversation context and manages compaction.
1717
type ContextBuilder struct {
18-
agent *Agent
19-
memory *MemoryStore
20-
grantID string
18+
agent *Agent
19+
memory *MemoryStore
20+
grantID string
21+
hasSlack bool
2122
}
2223

2324
// NewContextBuilder creates a new ContextBuilder.
24-
func NewContextBuilder(agent *Agent, memory *MemoryStore, grantID string) *ContextBuilder {
25+
func NewContextBuilder(agent *Agent, memory *MemoryStore, grantID string, hasSlack bool) *ContextBuilder {
2526
return &ContextBuilder{
26-
agent: agent,
27-
memory: memory,
28-
grantID: grantID,
27+
agent: agent,
28+
memory: memory,
29+
grantID: grantID,
30+
hasSlack: hasSlack,
2931
}
3032
}
3133

@@ -38,7 +40,7 @@ func (c *ContextBuilder) BuildPrompt(conv *Conversation, newMessage string) stri
3840
var sb strings.Builder
3941

4042
// System prompt
41-
sb.WriteString(BuildSystemPrompt(c.grantID, c.agent.Type))
43+
sb.WriteString(BuildSystemPrompt(c.grantID, c.agent.Type, c.hasSlack))
4244
sb.WriteString("\n---\n\n")
4345

4446
// Include conversation summary if available

internal/chat/context_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ func TestContextBuilder_BuildPrompt(t *testing.T) {
1212
agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"}
1313
store := setupMemoryStore(t)
1414
grantID := "test-grant-123"
15-
builder := NewContextBuilder(agent, store, grantID)
15+
builder := NewContextBuilder(agent, store, grantID, false)
1616

1717
tests := []struct {
1818
name string
@@ -124,7 +124,7 @@ func TestContextBuilder_BuildPrompt(t *testing.T) {
124124
func TestContextBuilder_NeedsCompaction(t *testing.T) {
125125
agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"}
126126
store := setupMemoryStore(t)
127-
builder := NewContextBuilder(agent, store, "test-grant")
127+
builder := NewContextBuilder(agent, store, "test-grant", false)
128128

129129
tests := []struct {
130130
name string
@@ -246,7 +246,7 @@ func TestContextBuilder_NeedsCompaction(t *testing.T) {
246246
func TestContextBuilder_findSplitIndex(t *testing.T) {
247247
agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"}
248248
store := setupMemoryStore(t)
249-
builder := NewContextBuilder(agent, store, "test-grant")
249+
builder := NewContextBuilder(agent, store, "test-grant", false)
250250

251251
tests := []struct {
252252
name string
@@ -372,7 +372,7 @@ func TestContextBuilder_findSplitIndex(t *testing.T) {
372372
func TestContextBuilder_BuildPrompt_Structure(t *testing.T) {
373373
agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"}
374374
store := setupMemoryStore(t)
375-
builder := NewContextBuilder(agent, store, "test-grant")
375+
builder := NewContextBuilder(agent, store, "test-grant", false)
376376

377377
conv := &Conversation{
378378
ID: "conv_test",

internal/chat/executor.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,18 @@ import (
1212
// ToolExecutor dispatches tool calls to the Nylas API.
1313
type ToolExecutor struct {
1414
client ports.NylasClient
15+
slack ports.SlackClient
1516
grantID string
1617
}
1718

1819
// NewToolExecutor creates a new ToolExecutor.
19-
func NewToolExecutor(client ports.NylasClient, grantID string) *ToolExecutor {
20-
return &ToolExecutor{client: client, grantID: grantID}
20+
func NewToolExecutor(client ports.NylasClient, grantID string, slack ports.SlackClient) *ToolExecutor {
21+
return &ToolExecutor{client: client, grantID: grantID, slack: slack}
22+
}
23+
24+
// HasSlack returns true if Slack integration is available.
25+
func (e *ToolExecutor) HasSlack() bool {
26+
return e.slack != nil
2127
}
2228

2329
// Execute runs a tool call and returns the result.
@@ -39,6 +45,19 @@ func (e *ToolExecutor) Execute(ctx context.Context, call ToolCall) ToolResult {
3945
return e.listContacts(ctx, call.Args)
4046
case "list_folders":
4147
return e.listFolders(ctx)
48+
// Slack tools
49+
case "list_slack_channels":
50+
return e.listSlackChannels(ctx, call.Args)
51+
case "read_slack_messages":
52+
return e.readSlackMessages(ctx, call.Args)
53+
case "read_slack_thread":
54+
return e.readSlackThread(ctx, call.Args)
55+
case "search_slack":
56+
return e.searchSlack(ctx, call.Args)
57+
case "send_slack_message":
58+
return e.sendSlackMessage(ctx, call.Args)
59+
case "list_slack_users":
60+
return e.listSlackUsers(ctx, call.Args)
4261
default:
4362
return ToolResult{Name: call.Name, Error: fmt.Sprintf("unknown tool: %s", call.Name)}
4463
}
@@ -160,8 +179,8 @@ func (e *ToolExecutor) searchEmails(ctx context.Context, args map[string]any) To
160179
}
161180

162181
params := &domain.MessageQueryParams{
163-
Limit: 10,
164-
SearchQuery: query,
182+
Limit: 10,
183+
Subject: query,
165184
}
166185

167186
if v, ok := args["limit"]; ok {

0 commit comments

Comments
 (0)