diff --git a/chatapps/base/types.go b/chatapps/base/types.go index 37d3ae3d..b6552b89 100644 --- a/chatapps/base/types.go +++ b/chatapps/base/types.go @@ -23,6 +23,14 @@ type RichContent struct { Blocks []any Embeds []any Attachments []Attachment + Reactions []Reaction +} + +// Reaction represents a reaction to add to a message +type Reaction struct { + Name string // emoji name (e.g., "thumbsup", "+1") + Channel string + Timestamp string // message timestamp to react to } type Attachment struct { diff --git a/chatapps/slack/adapter.go b/chatapps/slack/adapter.go index 9424ecf6..bbbd6fa1 100644 --- a/chatapps/slack/adapter.go +++ b/chatapps/slack/adapter.go @@ -19,12 +19,14 @@ import ( type Adapter struct { *base.Adapter - config Config - eventPath string - interactivePath string - sender *base.SenderWithMutex - webhook *base.WebhookRunner - socketMode *SocketModeConnection + config Config + eventPath string + interactivePath string + slashCommandPath string + sender *base.SenderWithMutex + webhook *base.WebhookRunner + socketMode *SocketModeConnection + slashCommandHandler func(cmd SlashCommand) } func NewAdapter(config Config, logger *slog.Logger, opts ...base.AdapterOption) *Adapter { @@ -34,11 +36,12 @@ func NewAdapter(config Config, logger *slog.Logger, opts ...base.AdapterOption) } a := &Adapter{ - config: config, - eventPath: "/events", - interactivePath: "/interactive", - sender: base.NewSenderWithMutex(), - webhook: base.NewWebhookRunner(logger), + config: config, + eventPath: "/events", + interactivePath: "/interactive", + slashCommandPath: "/slack", + sender: base.NewSenderWithMutex(), + webhook: base.NewWebhookRunner(logger), } // Initialize Socket Mode if configured @@ -61,6 +64,7 @@ func NewAdapter(config Config, logger *slog.Logger, opts ...base.AdapterOption) // Slack recommends using both Socket Mode and HTTP webhook together handlers[a.eventPath] = a.handleEvent handlers[a.interactivePath] = a.handleInteractive + handlers[a.slashCommandPath] = a.handleSlashCommand // Build HTTP handler map for path, handler := range handlers { @@ -108,6 +112,16 @@ func (a *Adapter) defaultSender(ctx context.Context, sessionID string, msg *base } } + // Send reactions if present + if msg.RichContent != nil && len(msg.RichContent.Reactions) > 0 { + for _, reaction := range msg.RichContent.Reactions { + reaction.Channel = channelID + if err := a.AddReaction(ctx, reaction); err != nil { + a.Logger().Error("Failed to add reaction", "error", err, "reaction", reaction.Name) + } + } + } + // Send media/attachments if present if msg.RichContent != nil && len(msg.RichContent.Attachments) > 0 { for _, attachment := range msg.RichContent.Attachments { @@ -300,6 +314,18 @@ func (a *Adapter) handleEventCallback(ctx context.Context, eventData json.RawMes return } + // Check user permission + if !a.config.IsUserAllowed(msgEvent.User) { + a.Logger().Debug("User blocked", "user_id", msgEvent.User) + return + } + + // Check channel permission + if !a.config.ShouldProcessChannel(msgEvent.ChannelType, msgEvent.Channel) { + a.Logger().Debug("Channel blocked by policy", "channel_type", msgEvent.ChannelType, "channel_id", msgEvent.Channel) + return + } + sessionID := a.GetOrCreateSession(msgEvent.Channel+":"+msgEvent.User, msgEvent.User) msg := &base.ChatMessage{ @@ -530,3 +556,104 @@ func (a *Adapter) sendToChannelOnce(ctx context.Context, channelID, text, thread a.Logger().Debug("Message sent successfully", "channel", channelID) return nil } + +// AddReaction adds a reaction to a message +func (a *Adapter) AddReaction(ctx context.Context, reaction base.Reaction) error { + if a.config.BotToken == "" { + return fmt.Errorf("slack bot token not configured") + } + + if reaction.Channel == "" || reaction.Timestamp == "" { + return fmt.Errorf("channel and timestamp are required for reaction") + } + + payload := map[string]any{ + "channel": reaction.Channel, + "name": reaction.Name, + "ts": reaction.Timestamp, + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://slack.com/api/reactions.add", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+a.config.BotToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("send request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("reaction add failed: %d %s", resp.StatusCode, string(respBody)) + } + + var slackResp struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + } + if err := json.NewDecoder(resp.Body).Decode(&slackResp); err != nil { + return fmt.Errorf("parse response: %w", err) + } + + if !slackResp.OK { + return fmt.Errorf("slack API error: %s", slackResp.Error) + } + + a.Logger().Debug("Reaction added", "emoji", reaction.Name, "channel", reaction.Channel) + return nil +} + +// SlashCommand represents a Slack slash command +type SlashCommand struct { + Command string + Text string + UserID string + ChannelID string + ResponseURL string +} + +// SetSlashCommandHandler sets the handler for slash commands +func (a *Adapter) SetSlashCommandHandler(fn func(cmd SlashCommand)) { + a.slashCommandHandler = fn +} + +// handleSlashCommand processes incoming slash commands +func (a *Adapter) handleSlashCommand(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if err := r.ParseForm(); err != nil { + a.Logger().Error("Parse slash command form failed", "error", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + cmd := SlashCommand{ + Command: r.FormValue("command"), + Text: r.FormValue("text"), + UserID: r.FormValue("user_id"), + ChannelID: r.FormValue("channel_id"), + ResponseURL: r.FormValue("response_url"), + } + + a.Logger().Debug("Slash command received", "command", cmd.Command, "text", cmd.Text, "user", cmd.UserID) + + // Acknowledge immediately + w.WriteHeader(http.StatusOK) + + // Process in background if handler is set + if a.slashCommandHandler != nil { + go a.slashCommandHandler(cmd) + } +} diff --git a/chatapps/slack/config.go b/chatapps/slack/config.go index f0831cd0..6c96c49b 100644 --- a/chatapps/slack/config.go +++ b/chatapps/slack/config.go @@ -1,6 +1,10 @@ package slack -import "fmt" +import ( + "fmt" + "regexp" + "strings" +) type Config struct { BotToken string @@ -11,13 +15,41 @@ type Config struct { Mode string // ServerAddr: HTTP server address (e.g., ":8080") ServerAddr string + + // Permission Policy for Direct Messages + // "allow" - Allow all DMs (default) + // "pairing" - Only allow when user is paired + // "block" - Block all DMs + DMPolicy string + + // Permission Policy for Group Messages + // "allow" - Allow all group messages (default) + // "mention" - Only allow when bot is mentioned + // "block" - Block all group messages + GroupPolicy string + + // AllowedUsers: List of user IDs who can interact with the bot (whitelist) + AllowedUsers []string + // BlockedUsers: List of user IDs who cannot interact with the bot (blacklist) + BlockedUsers []string } +// Token format patterns +var ( + botTokenRegex = regexp.MustCompile(`^xoxb-[0-9]+-[0-9]+-[a-zA-Z0-9]+$`) + appTokenRegex = regexp.MustCompile(`^xapp-[0-9]+-[0-9]+-[a-zA-Z0-9]+$`) + signingSecretRegex = regexp.MustCompile(`^[a-zA-Z0-9]+$`) +) + // Validate checks the configuration based on the selected mode func (c *Config) Validate() error { + // Bot token is always required if c.BotToken == "" { return fmt.Errorf("bot token is required") } + if !botTokenRegex.MatchString(c.BotToken) { + return fmt.Errorf("invalid bot token format: expected xoxb-*-*-*") + } switch c.Mode { case "", "http": @@ -25,15 +57,31 @@ func (c *Config) Validate() error { if c.SigningSecret == "" { return fmt.Errorf("signing secret is required for HTTP mode") } + if len(c.SigningSecret) < 32 { + return fmt.Errorf("signing secret too short: minimum 32 characters") + } + if !signingSecretRegex.MatchString(c.SigningSecret) { + return fmt.Errorf("invalid signing secret format: must be alphanumeric") + } case "socket": // Socket Mode requires AppToken if c.AppToken == "" { return fmt.Errorf("app token is required for Socket mode") } + if !appTokenRegex.MatchString(c.AppToken) { + return fmt.Errorf("invalid app token format: expected xapp-*-*-*") + } default: return fmt.Errorf("invalid mode: %s (use 'http' or 'socket')", c.Mode) } + // Validate ServerAddr if provided + if c.ServerAddr != "" { + if !strings.HasPrefix(c.ServerAddr, ":") && !strings.Contains(c.ServerAddr, ":") { + return fmt.Errorf("invalid server address format: use :8080 or host:port") + } + } + return nil } @@ -41,3 +89,54 @@ func (c *Config) Validate() error { func (c *Config) IsSocketMode() bool { return c.Mode == "socket" } + +// IsUserAllowed checks if a user is allowed to interact with the bot +func (c *Config) IsUserAllowed(userID string) bool { + // Check blocked list first + for _, blocked := range c.BlockedUsers { + if blocked == userID { + return false + } + } + + // If allowlist is set, check it + if len(c.AllowedUsers) > 0 { + for _, allowed := range c.AllowedUsers { + if allowed == userID { + return true + } + } + return false + } + + // No allowlist, user is allowed + return true +} + +// ShouldProcessChannel checks if messages from a channel should be processed +// channelType: "dm" or "channel" or "group" +func (c *Config) ShouldProcessChannel(channelType, channelID string) bool { + switch channelType { + case "dm": + switch c.DMPolicy { + case "block": + return false + case "pairing": + // TODO: Check if user is paired + return true + default: // "allow" + return true + } + case "channel", "group": + switch c.GroupPolicy { + case "block": + return false + case "mention": + // TODO: Check if bot was mentioned + return true + default: // "allow" + return true + } + } + return true +}