Skip to content
Closed
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
92 changes: 74 additions & 18 deletions cmd/slk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/gammons/slk/internal/ui/presencemenu"
"github.com/gammons/slk/internal/ui/reactionpicker"
"github.com/gammons/slk/internal/ui/sidebar"
"github.com/gammons/slk/internal/ui/slashpicker"
"github.com/gammons/slk/internal/ui/statusbar"
"github.com/gammons/slk/internal/ui/styles"
"github.com/gammons/slk/internal/ui/themeswitcher"
Expand Down Expand Up @@ -93,10 +94,10 @@ func (a sectionsProviderAdapter) OrderedSlackSections() []sidebar.SectionMeta {

// WorkspaceContext holds all state for a single connected workspace.
type WorkspaceContext struct {
Client *slackclient.Client
ConnMgr *slackclient.ConnectionManager
RTMHandler *rtmEventHandler
UserNames map[string]string
Client *slackclient.Client
ConnMgr *slackclient.ConnectionManager
RTMHandler *rtmEventHandler
UserNames map[string]string
// AvatarURLs maps userID -> avatar image URL. Populated from the
// local users cache at connect time (synchronous, before any
// goroutines spin up) and refreshed from the background
Expand All @@ -120,7 +121,7 @@ type WorkspaceContext struct {
// background users.list fetch and any on-demand resolveUser calls.
// Used during channel construction to bucket app DMs into a separate
// "Apps" sidebar section.
BotUserIDs map[string]bool
BotUserIDs map[string]bool
// SectionStore holds the user's Slack-native sidebar sections for
// this workspace. Nil when use_slack_sections is disabled, the
// REST bootstrap failed, or this workspace hasn't connected yet.
Expand Down Expand Up @@ -149,7 +150,7 @@ type WorkspaceContext struct {
// after a failed one. The UI uses it to decide whether to draw
// the "Threads list unavailable" banner.
SubscriptionsAvailable bool
Channels []sidebar.ChannelItem
Channels []sidebar.ChannelItem
// FinderItems is the merged list shown in the Ctrl+T finder. Initially
// contains only joined channels; the BrowseableChannelsLoadedMsg pipeline
// extends it with non-joined public channels in the background.
Expand All @@ -159,6 +160,7 @@ type WorkspaceContext struct {
UserID string
UnresolvedDMs []UnresolvedDM
CustomEmoji map[string]string // emoji name -> URL or "alias:target"
SlashCommands []slashpicker.Command
// Self presence and DND state for this workspace. Populated on connect
// and updated by manual_presence_change / dnd_updated WS events plus
// optimistic writes from the presence menu.
Expand Down Expand Up @@ -1011,6 +1013,20 @@ func run() error {
}
})

app.SetSlashCommandRunner(func(channelID, text string) tea.Msg {
wctx := router.Active()
if wctx == nil {
return ui.ToastMsg{Text: "Command failed: no active workspace"}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := wctx.Client.ExecuteSlashCommand(ctx, channelID, text); err != nil {
log.Printf("Warning: failed to execute slash command: %v", err)
return ui.ToastMsg{Text: "Command failed: " + err.Error()}
}
return ui.ToastMsg{Text: "Command sent"}
})

app.SetMessageEditor(func(channelID, ts, text string) tea.Msg {
wctx := router.Active()
if wctx == nil {
Expand Down Expand Up @@ -1313,6 +1329,7 @@ func run() error {
ExternalUsers: external,
UserID: wctx.UserID,
CustomEmoji: wctx.CustomEmoji,
SlashCommands: wctx.SlashCommands,
SectionsProvider: sectionsProviderAdapter{store: wctx.SectionStore},
}
})
Expand Down Expand Up @@ -1457,6 +1474,7 @@ func run() error {
ExternalUsers: external,
UserID: wctx.UserID,
CustomEmoji: wctx.CustomEmoji, // empty at this point; filled by the goroutine below
SlashCommands: wctx.SlashCommands,
SectionsProvider: sectionsProviderAdapter{store: wctx.SectionStore},
InitialActive: isInitial,
})
Expand Down Expand Up @@ -1485,19 +1503,19 @@ func run() error {
// Resolve unknown DM user names in background
if len(wctx.UnresolvedDMs) > 0 {
go func() {
for _, dm := range wctx.UnresolvedDMs {
resolved, isBot := resolveUser(wctx.Client, dm.UserID, wctx.UserNames, db, avatarCache)
if isBot {
wctx.BotUserIDs[dm.UserID] = true
}
if resolved != dm.UserID {
p.Send(ui.DMNameResolvedMsg{
ChannelID: dm.ChannelID,
DisplayName: resolved,
IsBot: isBot,
})
for _, dm := range wctx.UnresolvedDMs {
resolved, isBot := resolveUser(wctx.Client, dm.UserID, wctx.UserNames, db, avatarCache)
if isBot {
wctx.BotUserIDs[dm.UserID] = true
}
if resolved != dm.UserID {
p.Send(ui.DMNameResolvedMsg{
ChannelID: dm.ChannelID,
DisplayName: resolved,
IsBot: isBot,
})
}
}
}
}()
}
}(ot.Token)
Expand Down Expand Up @@ -1532,6 +1550,38 @@ func run() error {
return err
}

func buildSlashPickerCommands(commands []slackclient.SlashCommand) []slashpicker.Command {
out := make([]slashpicker.Command, 0, len(commands))
for _, cmd := range commands {
if cmd.Command == "" {
continue
}
out = append(out, slashpicker.Command{
Name: cmd.Command,
Description: cmd.Description,
UsageHint: cmd.UsageHint,
})
}
return out
}

func defaultSlashPickerCommands() []slashpicker.Command {
return []slashpicker.Command{
{Name: "/away", Description: "Set yourself away"},
{Name: "/active", Description: "Set yourself active"},
{Name: "/dnd", Description: "Manage do not disturb", UsageHint: "[minutes]"},
{Name: "/invite", Description: "Invite people to this channel", UsageHint: "@user"},
{Name: "/leave", Description: "Leave the current channel"},
{Name: "/me", Description: "Post an emote message", UsageHint: "action"},
{Name: "/msg", Description: "Open a direct message", UsageHint: "@user message"},
{Name: "/remind", Description: "Create a reminder", UsageHint: "who what when"},
{Name: "/shrug", Description: "Append a shrug", UsageHint: "message"},
{Name: "/status", Description: "Set your status", UsageHint: "text"},
{Name: "/topic", Description: "Set the channel topic", UsageHint: "text"},
{Name: "/who", Description: "See who is in the channel"},
}
}

func connectWorkspace(ctx context.Context, token slackclient.Token, db *cache.DB, cfg config.Config, avatarCache *avatar.Cache, p *tea.Program) (*WorkspaceContext, error) {
client := slackclient.NewClient(token.AccessToken, token.Cookie)
if err := client.Connect(ctx); err != nil {
Expand All @@ -1548,6 +1598,7 @@ func connectWorkspace(ctx context.Context, token slackclient.Token, db *cache.DB
UserNamesByHandle: make(map[string]string),
BotUserIDs: make(map[string]bool),
CustomEmoji: make(map[string]string),
SlashCommands: defaultSlashPickerCommands(),
LastVisitedByChannel: make(map[string]int64),
}
wctx.SubscriptionsAvailable = true
Expand Down Expand Up @@ -1632,6 +1683,11 @@ func connectWorkspace(ctx context.Context, token slackclient.Token, db *cache.DB
} else {
wctx.LastVisitedByChannel = visits
}
if commands, err := client.ListSlashCommands(ctx); err == nil {
if pickerCommands := buildSlashPickerCommands(commands); len(pickerCommands) > 0 {
wctx.SlashCommands = pickerCommands
}
}

// Initialize Slack-native section store if enabled. Bootstrap is
// best-effort: failure is logged, the field stays nil, and the
Expand Down
109 changes: 109 additions & 0 deletions internal/slack/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,12 @@ type HistorySinceResult struct {
Capped bool
}

type SlashCommand struct {
Command string
Description string
UsageHint string
}

// GetHistorySince fetches all messages newer than `oldest` for the
// given channel, paginating through next_cursor up to a hard ceiling
// of maxTotal messages. Slack returns messages newest-first per page;
Expand Down Expand Up @@ -639,6 +645,109 @@ func (c *Client) SendMessage(ctx context.Context, channelID, text string) (strin
return ts, mr, nil
}

func (c *Client) ExecuteSlashCommand(ctx context.Context, channelID, text string) error {
command, args, ok := splitSlashCommand(text)
if channelID == "" {
return fmt.Errorf("executing slash command: missing channel")
}
if !ok {
return fmt.Errorf("executing slash command: invalid command")
}

body, err := c.postForm(ctx, "chat.command", url.Values{
"channel": {channelID},
"command": {command},
"text": {args},
})
if err != nil {
return fmt.Errorf("executing slash command: %w", err)
}

var resp struct {
OK bool `json:"ok"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("parsing chat.command response: %w (body=%q)", err, truncateForLog(body))
}
if !resp.OK {
if resp.Error == "" {
resp.Error = "unknown_error"
}
return fmt.Errorf("chat.command: %s", resp.Error)
}
return nil
}

func splitSlashCommand(text string) (command, args string, ok bool) {
trimmed := strings.TrimSpace(text)
if !strings.HasPrefix(trimmed, "/") || len(trimmed) == 1 {
return "", "", false
}
for i, r := range trimmed {
if i == 0 {
continue
}
if r == ' ' || r == '\t' || r == '\n' {
return trimmed[:i], strings.TrimSpace(trimmed[i:]), true
}
}
return trimmed, "", true
}

func (c *Client) ListSlashCommands(ctx context.Context) ([]SlashCommand, error) {
body, err := c.postForm(ctx, "commands.list", nil)
if err != nil {
return nil, fmt.Errorf("listing slash commands: %w", err)
}

var resp struct {
OK bool `json:"ok"`
Error string `json:"error"`
Commands []struct {
Command string `json:"command"`
Name string `json:"name"`
Description string `json:"description"`
UsageHint string `json:"usage_hint"`
ArgHint string `json:"arg_hint"`
Hint string `json:"hint"`
} `json:"commands"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parsing commands.list response: %w (body=%q)", err, truncateForLog(body))
}
if !resp.OK {
if resp.Error == "" {
resp.Error = "unknown_error"
}
return nil, fmt.Errorf("commands.list: %s", resp.Error)
}

commands := make([]SlashCommand, 0, len(resp.Commands))
for _, cmd := range resp.Commands {
name := cmd.Command
if name == "" {
name = cmd.Name
}
if name == "" {
continue
}
hint := cmd.UsageHint
if hint == "" {
hint = cmd.ArgHint
}
if hint == "" {
hint = cmd.Hint
}
commands = append(commands, SlashCommand{
Command: name,
Description: cmd.Description,
UsageHint: hint,
})
}
return commands, nil
}

// UploadFile uploads a single file to a channel (and optional thread)
// using Slack's V2 external-upload flow. The slack-go library's
// UploadFileContext (named for the underlying file.upload.v2 API)
Expand Down
52 changes: 52 additions & 0 deletions internal/slack/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,58 @@ func TestSendMessage_EmptyTextSendsNoBlocks(t *testing.T) {
}
}

func TestExecuteSlashCommand_PostsChatCommandForm(t *testing.T) {
var gotPath string
var gotAuth string
var gotForm url.Values
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
if err := r.ParseForm(); err != nil {
t.Errorf("ParseForm: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
gotForm = r.Form
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()

c := &Client{token: "xoxc-test", cookie: "d-cookie", apiBaseURL: srv.URL + "/api/"}
if err := c.ExecuteSlashCommand(context.Background(), "C1", "/invite @alice"); err != nil {
t.Fatalf("ExecuteSlashCommand: %v", err)
}
if gotPath != "/api/chat.command" {
t.Errorf("path = %q, want /api/chat.command", gotPath)
}
if gotAuth != "Bearer xoxc-test" {
t.Errorf("Authorization = %q, want bearer token", gotAuth)
}
if gotForm.Get("channel") != "C1" {
t.Errorf("channel = %q, want C1", gotForm.Get("channel"))
}
if gotForm.Get("command") != "/invite" {
t.Errorf("command = %q, want /invite", gotForm.Get("command"))
}
if gotForm.Get("text") != "@alice" {
t.Errorf("text = %q, want @alice", gotForm.Get("text"))
}
}

func TestExecuteSlashCommand_ReturnsSlackError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":false,"error":"unknown_command"}`))
}))
defer srv.Close()

c := &Client{token: "xoxc-test", cookie: "d-cookie", apiBaseURL: srv.URL + "/api/"}
if err := c.ExecuteSlashCommand(context.Background(), "C1", "/doesnotexist"); err == nil || !strings.Contains(err.Error(), "unknown_command") {
t.Fatalf("expected unknown_command error, got %v", err)
}
}

// mockSlackAPI implements SlackAPI for testing.
// Function fields allow tests to override default behavior.
type mockSlackAPI struct {
Expand Down
Loading