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
8 changes: 8 additions & 0 deletions chatapps/base/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
149 changes: 138 additions & 11 deletions chatapps/slack/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
}
101 changes: 100 additions & 1 deletion chatapps/slack/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package slack

import "fmt"
import (
"fmt"
"regexp"
"strings"
)

type Config struct {
BotToken string
Expand All @@ -11,33 +15,128 @@ 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":
// HTTP Mode requires SigningSecret
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
}

// IsSocketMode returns true if Socket Mode is enabled
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
}