package service import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "strings" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) var ErrSlackInvalidChannel = errors.New("invalid slack channel") // SlackIntegrationService implements Slack integration business logic. // Reference: Chatwoot Integrations::SlackController + SlackService // Slack integration sends conversation notifications to a Slack channel and // supports Slack slash commands for ticket creation. type SlackIntegrationService struct { hookRepo *repository.IntegrationHookRepo client *slackAPIClient } // NewSlackIntegrationService creates a new SlackIntegrationService. func NewSlackIntegrationService(hookRepo *repository.IntegrationHookRepo) *SlackIntegrationService { return &SlackIntegrationService{hookRepo: hookRepo, client: newSlackAPIClientFromEnv()} } // CreateSlackRequest is the DTO for creating/updating a Slack integration. // Reference: Chatwoot SlackController#create — params: {code, inbox_id} type CreateSlackRequest struct { Code string `json:"code,omitempty"` InboxID *uint `json:"inbox_id,omitempty"` ChannelID string `json:"channel_id" validate:"required"` ChannelName string `json:"channel_name,omitempty"` SlackToken string `json:"slack_token,omitempty"` } // UpdateSlackRequest is the DTO for updating a Slack integration. type UpdateSlackRequest struct { ReferenceID string `json:"reference_id,omitempty"` ChannelID string `json:"channel_id,omitempty"` ChannelName string `json:"channel_name,omitempty"` SlackToken string `json:"slack_token,omitempty"` } // Create creates a Slack integration hook for an account. func (s *SlackIntegrationService) Create(ctx context.Context, accountID uint, req CreateSlackRequest) (*model.IntegrationHook, error) { accessToken := strings.TrimSpace(req.SlackToken) if accessToken == "" { var err error accessToken, err = s.client.exchangeOAuthCode(ctx, accountID, req.Code) if err != nil { return nil, err } } settingsJSON, err := json.Marshal(slackSettingsFromCreate(req)) if err != nil { return nil, fmt.Errorf("failed to marshal Slack settings: %w", err) } hook := &model.IntegrationHook{ AccountID: accountID, AppID: "slack", InboxID: req.InboxID, HookType: model.HookTypeSlack, Status: model.HookStatusInactive, AccessToken: accessToken, Settings: settingsJSON, } if err := s.hookRepo.Create(ctx, hook); err != nil { return nil, fmt.Errorf("failed to create Slack integration: %w", err) } applogger.L().Infof("Slack integration created: account=%d", accountID) return hook, nil } // Update updates a Slack integration hook. func (s *SlackIntegrationService) Update(ctx context.Context, accountID uint, req UpdateSlackRequest) (*model.IntegrationHook, error) { hooks, err := s.findSlackHooks(ctx, accountID) if err != nil || len(hooks) == 0 { return nil, fmt.Errorf("Slack integration not found for account %d", accountID) } hook := &hooks[0] referenceID := firstNonBlank(req.ReferenceID, req.ChannelID) channel, err := s.findChannel(ctx, *hook, referenceID) if err != nil { return nil, err } if channel == nil { return nil, ErrSlackInvalidChannel } if !channel.IsPrivate { if err := s.client.joinChannel(ctx, hook.AccessToken, channel.ID); err != nil { return nil, err } } settings := model.SlackSettings{ChannelName: channel.Name} if req.SlackToken != "" { hook.AccessToken = req.SlackToken settings.SlackToken = req.SlackToken } settingsJSON, err := json.Marshal(settings) if err != nil { return nil, fmt.Errorf("failed to marshal Slack settings: %w", err) } hook.Settings = settingsJSON hook.AppID = "slack" hook.ReferenceID = channel.ID hook.Status = model.HookStatusActive if err := s.hookRepo.Update(ctx, hook); err != nil { return nil, fmt.Errorf("failed to update Slack integration: %w", err) } applogger.L().Infof("Slack integration updated: account=%d", accountID) return hook, nil } // Delete removes a Slack integration hook for an account. func (s *SlackIntegrationService) Delete(ctx context.Context, accountID uint) error { hooks, err := s.findSlackHooks(ctx, accountID) if err != nil || len(hooks) == 0 { return fmt.Errorf("Slack integration not found for account %d", accountID) } for _, hook := range hooks { if err := s.hookRepo.Delete(ctx, hook.ID); err != nil { return fmt.Errorf("failed to delete Slack integration: %w", err) } } applogger.L().Infof("Slack integration deleted: account=%d", accountID) return nil } // ListAllChannels returns available Slack channels (proxy to Slack API). // GET /api/v1/accounts/:account_id/integrations/slack/list_all_channels func (s *SlackIntegrationService) ListAllChannels(ctx context.Context, accountID uint) ([]map[string]interface{}, error) { hooks, err := s.findSlackHooks(ctx, accountID) if err != nil || len(hooks) == 0 { return nil, fmt.Errorf("Slack integration not found for account %d", accountID) } channels, err := s.client.listChannels(ctx, hooks[0].AccessToken) if err != nil { return nil, err } applogger.L().Infof("Listing Slack channels for account=%d", accountID) return slackChannelsToMaps(channels), nil } func (s *SlackIntegrationService) ListHooks(ctx context.Context, accountID uint) ([]model.IntegrationHook, error) { return s.findSlackHooks(ctx, accountID) } func (s *SlackIntegrationService) findSlackHooks(ctx context.Context, accountID uint) ([]model.IntegrationHook, error) { hooks, err := s.hookRepo.FindByAccountAndApp(ctx, accountID, "slack") if err != nil { return nil, err } if len(hooks) > 0 { return hooks, nil } return s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeSlack) } func (s *SlackIntegrationService) findChannel(ctx context.Context, hook model.IntegrationHook, referenceID string) (*slackChannel, error) { if referenceID == "" { return nil, ErrSlackInvalidChannel } channels, err := s.client.listChannels(ctx, hook.AccessToken) if err != nil { return nil, err } for _, channel := range channels { if channel.ID == referenceID { return &channel, nil } } return nil, nil } func slackSettingsFromCreate(req CreateSlackRequest) model.SlackSettings { return model.SlackSettings{ ChannelID: req.ChannelID, ChannelName: req.ChannelName, SlackToken: req.SlackToken, } } func firstNonBlank(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } type slackAPIClient struct { baseURL string httpClient *http.Client } type slackChannel struct { ID string `json:"id"` Name string `json:"name"` IsPrivate bool `json:"is_private"` } func newSlackAPIClientFromEnv() *slackAPIClient { baseURL := strings.TrimRight(os.Getenv("SLACK_API_BASE"), "/") if baseURL == "" { baseURL = "https://slack.com/api" } return &slackAPIClient{baseURL: baseURL, httpClient: &http.Client{Timeout: 15 * time.Second}} } func (c *slackAPIClient) exchangeOAuthCode(ctx context.Context, accountID uint, code string) (string, error) { if strings.TrimSpace(code) == "" { return "", fmt.Errorf("slack oauth code is required") } form := url.Values{} form.Set("client_id", os.Getenv("SLACK_CLIENT_ID")) form.Set("client_secret", os.Getenv("SLACK_CLIENT_SECRET")) form.Set("code", code) form.Set("redirect_uri", fmt.Sprintf("%s/app/accounts/%d/settings/integrations/slack", strings.TrimRight(os.Getenv("FRONTEND_URL"), "/"), accountID)) var payload struct { OK bool `json:"ok"` AccessToken string `json:"access_token"` Error string `json:"error"` } if err := c.postForm(ctx, "/oauth.v2.access", "", form, &payload); err != nil { return "", err } if !payload.OK || payload.AccessToken == "" { return "", fmt.Errorf("slack oauth failed: %s", payload.Error) } return payload.AccessToken, nil } func (c *slackAPIClient) listChannels(ctx context.Context, token string) ([]slackChannel, error) { var channels []slackChannel for _, channelType := range []string{"private_channel", "public_channel"} { cursor := "" for { batch, nextCursor, err := c.listChannelsByType(ctx, token, channelType, cursor) if err != nil { return nil, err } channels = append(channels, batch...) if nextCursor == "" { break } cursor = nextCursor } } return channels, nil } func (c *slackAPIClient) listChannelsByType(ctx context.Context, token, channelType, cursor string) ([]slackChannel, string, error) { query := url.Values{} query.Set("types", channelType) query.Set("exclude_archived", "true") query.Set("limit", "1000") if cursor != "" { query.Set("cursor", cursor) } var payload struct { OK bool `json:"ok"` Channels []slackChannel `json:"channels"` Error string `json:"error"` ResponseMetadata struct { NextCursor string `json:"next_cursor"` } `json:"response_metadata"` } if err := c.get(ctx, "/conversations.list?"+query.Encode(), token, &payload); err != nil { return nil, "", err } if !payload.OK { return nil, "", fmt.Errorf("slack conversations.list failed: %s", payload.Error) } return payload.Channels, payload.ResponseMetadata.NextCursor, nil } func (c *slackAPIClient) joinChannel(ctx context.Context, token, channelID string) error { form := url.Values{} form.Set("channel", channelID) var payload struct { OK bool `json:"ok"` Error string `json:"error"` } if err := c.postForm(ctx, "/conversations.join", token, form, &payload); err != nil { return err } if !payload.OK { return fmt.Errorf("slack conversations.join failed: %s", payload.Error) } return nil } func (c *slackAPIClient) get(ctx context.Context, path, token string, out interface{}) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) if err != nil { return err } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } return c.do(req, out) } func (c *slackAPIClient) postForm(ctx context.Context, path, token string, form url.Values, out interface{}) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, strings.NewReader(form.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } return c.do(req, out) } func (c *slackAPIClient) do(req *http.Request, out interface{}) error { resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode >= 400 { return fmt.Errorf("slack api returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } if err := json.Unmarshal(body, out); err != nil { return err } return nil } func slackChannelsToMaps(channels []slackChannel) []map[string]interface{} { items := make([]map[string]interface{}, 0, len(channels)) for _, channel := range channels { items = append(items, map[string]interface{}{ "id": channel.ID, "name": channel.Name, "is_private": channel.IsPrivate, }) } return items }