package service import ( "bytes" "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" "github.com/golang-jwt/jwt/v5" ) // LinearProviderError mirrors Chatwoot's `{ error: ... }`, 422 provider failures. type LinearProviderError struct { Message interface{} } func (e *LinearProviderError) Error() string { if e == nil || e.Message == nil { return "linear provider error" } if msg, ok := e.Message.(string); ok { return msg } b, err := json.Marshal(e.Message) if err != nil { return fmt.Sprint(e.Message) } return string(b) } // LinearIntegrationService implements Linear integration business logic. // Reference: Chatwoot Integrations::LinearController // Linear integration creates and links issues from conversations. type LinearIntegrationService struct { hookRepo *repository.IntegrationHookRepo conversationRepo *repository.ConversationRepo messageRepo *repository.MessageRepo userRepo *repository.UserRepo client *linearAPIClient } // NewLinearIntegrationService creates a new LinearIntegrationService. func NewLinearIntegrationService(hookRepo *repository.IntegrationHookRepo) *LinearIntegrationService { svc := &LinearIntegrationService{hookRepo: hookRepo, client: newLinearAPIClientFromEnv()} if hookRepo != nil && hookRepo.DB() != nil { db := hookRepo.DB() svc.conversationRepo = repository.NewConversationRepo(db) svc.messageRepo = repository.NewMessageRepo(db) svc.userRepo = repository.NewUserRepo(db) } return svc } // Delete removes a Linear integration hook for an account. func (s *LinearIntegrationService) Delete(ctx context.Context, accountID uint) error { hooks, err := s.findLinearHooks(ctx, accountID) if err != nil || len(hooks) == 0 { return fmt.Errorf("Linear integration not found for account %d", accountID) } for _, hook := range hooks { _ = s.client.revokeToken(ctx, hook.AccessToken, linearRefreshToken(hook.Settings)) if err := s.hookRepo.Delete(ctx, hook.ID); err != nil { return fmt.Errorf("failed to delete Linear integration: %w", err) } } applogger.L().Infof("Linear integration deleted: account=%d", accountID) return nil } // GetTeams retrieves available Linear teams (proxy to Linear API). // GET /api/v1/accounts/:account_id/integrations/linear/teams func (s *LinearIntegrationService) GetTeams(ctx context.Context, accountID uint) ([]map[string]interface{}, error) { client, err := s.linearClient(ctx, accountID) if err != nil { return nil, err } data, err := client.teams(ctx) if err != nil { return nil, err } applogger.L().Infof("Listing Linear teams for account=%d", accountID) return nodesFromPath(data, "teams"), nil } // GetTeamEntities retrieves entities from a Linear team. // GET /api/v1/accounts/:account_id/integrations/linear/team_entities func (s *LinearIntegrationService) GetTeamEntities(ctx context.Context, accountID uint, teamID string) (map[string]interface{}, error) { client, err := s.linearClient(ctx, accountID) if err != nil { return nil, err } data, err := client.teamEntities(ctx, teamID) if err != nil { return nil, err } applogger.L().Infof("Listing Linear team entities for account=%d", accountID) return map[string]interface{}{ "users": nodesFromPath(data, "users"), "projects": nodesFromPath(data, "projects"), "states": nodesFromPath(data, "workflowStates"), "labels": nodesFromPath(data, "issueLabels"), }, nil } // CreateIssueRequest is the DTO for creating a Linear issue. type CreateIssueRequest struct { Title string `json:"title" form:"title"` Description string `json:"description,omitempty" form:"description"` TeamID string `json:"team_id,omitempty" form:"team_id"` ProjectID string `json:"project_id,omitempty" form:"project_id"` ConversationID uint `json:"conversation_id,omitempty" form:"conversation_id"` AssigneeID string `json:"assignee_id,omitempty" form:"assignee_id"` Priority interface{} `json:"priority,omitempty" form:"priority"` StateID string `json:"state_id,omitempty" form:"state_id"` LabelIDs []string `json:"label_ids,omitempty" form:"label_ids[]"` } // CreateIssue creates a Linear issue from a conversation. // POST /api/v1/accounts/:account_id/integrations/linear/create_issue func (s *LinearIntegrationService) CreateIssue(ctx context.Context, accountID uint, req CreateIssueRequest, userID ...uint) (map[string]interface{}, error) { conversation, err := s.findConversation(ctx, accountID, req.ConversationID) if err != nil { return nil, err } client, err := s.linearClient(ctx, accountID) if err != nil { return nil, err } user := s.findUser(ctx, optionalUint(userID)) data, err := client.createIssue(ctx, req, user) if err != nil { return nil, err } issue := map[string]interface{}{ "id": nestedString(data, "issueCreate", "issue", "id"), "title": nestedString(data, "issueCreate", "issue", "title"), "identifier": nestedString(data, "issueCreate", "issue", "identifier"), } s.createLinearActivity(ctx, conversation, user, "created", fmt.Sprint(issue["identifier"])) applogger.L().Infof("Creating Linear issue for account=%d: title=%s", accountID, req.Title) return issue, nil } // LinkIssueRequest is the DTO for linking a Linear issue to a conversation. type LinkIssueRequest struct { IssueID string `json:"issue_id" form:"issue_id"` ConversationID uint `json:"conversation_id,omitempty" form:"conversation_id"` Title string `json:"title,omitempty" form:"title"` } // LinkIssue links a Linear issue to a conversation. // POST /api/v1/accounts/:account_id/integrations/linear/link_issue func (s *LinearIntegrationService) LinkIssue(ctx context.Context, accountID uint, req LinkIssueRequest, userID ...uint) (map[string]interface{}, error) { conversation, err := s.findConversation(ctx, accountID, req.ConversationID) if err != nil { return nil, err } client, err := s.linearClient(ctx, accountID) if err != nil { return nil, err } user := s.findUser(ctx, optionalUint(userID)) link := linearConversationLink(accountID, conversation) data, err := client.linkIssue(ctx, link, req.IssueID, req.Title, user) if err != nil { return nil, err } result := map[string]interface{}{ "id": req.IssueID, "link": link, "link_id": nestedString(data, "attachmentLinkURL", "attachment", "id"), } s.createLinearActivity(ctx, conversation, user, "linked", req.IssueID) applogger.L().Infof("Linking Linear issue %s for account=%d", req.IssueID, accountID) return result, nil } // UnlinkIssueRequest is the DTO for unlinking a Linear issue from a conversation. type UnlinkIssueRequest struct { IssueID string `json:"issue_id" form:"issue_id"` LinkID string `json:"link_id" form:"link_id"` ConversationID uint `json:"conversation_id,omitempty" form:"conversation_id"` } // UnlinkIssue unlinks a Linear issue from a conversation. // POST /api/v1/accounts/:account_id/integrations/linear/unlink_issue func (s *LinearIntegrationService) UnlinkIssue(ctx context.Context, accountID uint, req UnlinkIssueRequest, userID ...uint) (map[string]interface{}, error) { conversation, err := s.findConversation(ctx, accountID, req.ConversationID) if err != nil { return nil, err } client, err := s.linearClient(ctx, accountID) if err != nil { return nil, err } if _, err := client.unlinkIssue(ctx, req.LinkID); err != nil { return nil, err } user := s.findUser(ctx, optionalUint(userID)) s.createLinearActivity(ctx, conversation, user, "unlinked", req.IssueID) applogger.L().Infof("Unlinking Linear issue %s for account=%d", req.IssueID, accountID) return map[string]interface{}{ "link_id": req.LinkID, }, nil } // SearchIssue searches Linear issues. // GET /api/v1/accounts/:account_id/integrations/linear/search_issue func (s *LinearIntegrationService) SearchIssue(ctx context.Context, accountID uint, query string) ([]map[string]interface{}, error) { client, err := s.linearClient(ctx, accountID) if err != nil { return nil, err } data, err := client.searchIssue(ctx, query) if err != nil { return nil, err } applogger.L().Infof("Searching Linear issues for account=%d, query=%s", accountID, query) return nodesFromPath(data, "searchIssues"), nil } // GetLinkedIssues retrieves Linear issues linked to a conversation. // GET /api/v1/accounts/:account_id/integrations/linear/linked_issues func (s *LinearIntegrationService) GetLinkedIssues(ctx context.Context, accountID uint, conversationID uint) ([]map[string]interface{}, error) { conversation, err := s.findConversation(ctx, accountID, conversationID) if err != nil { return nil, err } client, err := s.linearClient(ctx, accountID) if err != nil { return nil, err } data, err := client.linkedIssues(ctx, linearConversationLink(accountID, conversation)) if err != nil { return nil, err } applogger.L().Infof("Listing linked Linear issues for account=%d, conversation=%d", accountID, conversationID) return nodesFromPath(data, "attachmentsForURL"), nil } func (s *LinearIntegrationService) findLinearHooks(ctx context.Context, accountID uint) ([]model.IntegrationHook, error) { hooks, err := s.hookRepo.FindByAccountAndApp(ctx, accountID, "linear") if err != nil { return nil, err } if len(hooks) > 0 { return hooks, nil } return s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear) } func (s *LinearIntegrationService) linearClient(ctx context.Context, accountID uint) (*linearBoundClient, error) { hooks, err := s.findLinearHooks(ctx, accountID) if err != nil || len(hooks) == 0 { return nil, fmt.Errorf("Linear integration not found for account %d", accountID) } token := strings.TrimSpace(hooks[0].AccessToken) if token == "" { var settings model.LinearSettings _ = json.Unmarshal(hooks[0].Settings, &settings) token = strings.TrimSpace(settings.AccessToken) } if token == "" { return nil, &LinearProviderError{Message: "Missing Credentials"} } return &linearBoundClient{client: s.client, token: token}, nil } func (s *LinearIntegrationService) findConversation(ctx context.Context, accountID, displayID uint) (*model.Conversation, error) { if s.conversationRepo == nil || displayID == 0 { return nil, fmt.Errorf("conversation not found") } return s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, displayID) } func (s *LinearIntegrationService) findUser(ctx context.Context, userID uint) *model.User { if s.userRepo == nil || userID == 0 { return nil } user, err := s.userRepo.FindByID(ctx, userID) if err != nil { return nil } return user } func (s *LinearIntegrationService) createLinearActivity(ctx context.Context, conversation *model.Conversation, user *model.User, action, issueID string) { if s.messageRepo == nil || conversation == nil || user == nil || issueID == "" { return } content := fmt.Sprintf("Linear issue %s was %s by %s", issueID, action, user.Name) message := &model.Message{ ConversationID: conversation.ID, AccountID: conversation.AccountID, InboxID: conversation.InboxID, MessageType: "activity", ContentType: "text", Status: "sent", Content: content, } if err := s.messageRepo.Create(ctx, message); err != nil { applogger.L().Warnf("failed to create Linear activity message: %v", err) } } func linearConversationLink(accountID uint, conversation *model.Conversation) string { displayID := conversation.ID if conversation.DisplayID != nil && *conversation.DisplayID != 0 { displayID = *conversation.DisplayID } base := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/") return fmt.Sprintf("%s/app/accounts/%d/conversations/%d", base, accountID, displayID) } func optionalUint(values []uint) uint { if len(values) == 0 { return 0 } return values[0] } func linearRefreshToken(settingsJSON []byte) string { var settings map[string]interface{} if err := json.Unmarshal(settingsJSON, &settings); err != nil { return "" } if token, ok := settings["refresh_token"].(string); ok { return token } return "" } type linearAPIClient struct { graphqlURL string revokeURL string httpClient *http.Client } type linearBoundClient struct { client *linearAPIClient token string } func newLinearAPIClientFromEnv() *linearAPIClient { baseURL := strings.TrimRight(os.Getenv("LINEAR_API_BASE"), "/") if baseURL == "" { baseURL = "https://api.linear.app" } return &linearAPIClient{ graphqlURL: baseURL + "/graphql", revokeURL: baseURL + "/oauth/revoke", httpClient: &http.Client{Timeout: 15 * time.Second}, } } func (c *linearBoundClient) teams(ctx context.Context) (map[string]interface{}, error) { return c.postGraphQL(ctx, `query { teams { nodes { id name } } }`) } func (c *linearBoundClient) teamEntities(ctx context.Context, teamID string) (map[string]interface{}, error) { if strings.TrimSpace(teamID) == "" { return nil, &LinearProviderError{Message: "Missing team id"} } query := fmt.Sprintf(`query { users { nodes { id name } } projects { nodes { id name } } workflowStates(filter: { team: { id: { eq: %s } } }) { nodes { id name } } issueLabels(filter: { team: { id: { eq: %s } } }) { nodes { id name } } }`, graphqlValue(teamID), graphqlValue(teamID)) return c.postGraphQL(ctx, query) } func (c *linearBoundClient) createIssue(ctx context.Context, req CreateIssueRequest, user *model.User) (map[string]interface{}, error) { input := map[string]interface{}{ "title": req.Title, "teamId": req.TeamID, "description": req.Description, "assigneeId": req.AssigneeID, "priority": req.Priority, "labelIds": req.LabelIDs, "projectId": req.ProjectID, "stateId": req.StateID, } if user != nil && user.Name != "" { input["createAsUser"] = user.Name if user.AvatarURL != "" { input["displayIconUrl"] = user.AvatarURL } } query := fmt.Sprintf(`mutation { issueCreate(input: { %s }) { success issue { id title identifier } } }`, graphqlInput(input)) return c.postGraphQL(ctx, query) } func (c *linearBoundClient) linkIssue(ctx context.Context, link, issueID, title string, user *model.User) (map[string]interface{}, error) { if strings.TrimSpace(link) == "" { return nil, &LinearProviderError{Message: "Missing link"} } if strings.TrimSpace(issueID) == "" { return nil, &LinearProviderError{Message: "Missing issue id"} } parts := []string{ "url: " + graphqlValue(link), "issueId: " + graphqlValue(issueID), "title: " + graphqlValue(title), } if user != nil && user.Name != "" { parts = append(parts, "createAsUser: "+graphqlValue(user.Name)) if user.AvatarURL != "" { parts = append(parts, "displayIconUrl: "+graphqlValue(user.AvatarURL)) } } query := fmt.Sprintf(`mutation { attachmentLinkURL(%s) { success attachment { id } } }`, strings.Join(parts, ", ")) return c.postGraphQL(ctx, query) } func (c *linearBoundClient) unlinkIssue(ctx context.Context, linkID string) (map[string]interface{}, error) { if strings.TrimSpace(linkID) == "" { return nil, &LinearProviderError{Message: "Missing link id"} } query := fmt.Sprintf(`mutation { attachmentDelete(id: %s) { success } }`, graphqlValue(linkID)) return c.postGraphQL(ctx, query) } func (c *linearBoundClient) searchIssue(ctx context.Context, term string) (map[string]interface{}, error) { if strings.TrimSpace(term) == "" { return nil, &LinearProviderError{Message: "Missing search term"} } query := fmt.Sprintf(`query { searchIssues(term: %s) { nodes { id title description identifier url state { name color } } } }`, graphqlValue(term)) return c.postGraphQL(ctx, query) } func (c *linearBoundClient) linkedIssues(ctx context.Context, link string) (map[string]interface{}, error) { if strings.TrimSpace(link) == "" { return nil, &LinearProviderError{Message: "Missing link"} } query := fmt.Sprintf(`query { attachmentsForURL(url: %s) { nodes { id title issue { id identifier title description priority createdAt url assignee { name avatarUrl } state { name color } labels { nodes { id name color description } } } } } }`, graphqlValue(link)) return c.postGraphQL(ctx, query) } func (c *linearBoundClient) postGraphQL(ctx context.Context, query string) (map[string]interface{}, error) { return c.client.postGraphQL(ctx, c.token, query) } func (c *linearAPIClient) postGraphQL(ctx context.Context, token, query string) (map[string]interface{}, error) { body, err := json.Marshal(map[string]string{"query": query}) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphqlURL, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var payload map[string]interface{} if len(raw) > 0 { if err := json.Unmarshal(raw, &payload); err != nil { return nil, err } } if resp.StatusCode < 200 || resp.StatusCode >= 300 || payload["data"] == nil { if len(payload) > 0 { return nil, &LinearProviderError{Message: payload} } return nil, &LinearProviderError{Message: strings.TrimSpace(string(raw))} } data, ok := payload["data"].(map[string]interface{}) if !ok { return nil, &LinearProviderError{Message: payload} } return data, nil } func (c *linearAPIClient) revokeToken(ctx context.Context, accessToken, refreshToken string) error { token := strings.TrimSpace(refreshToken) tokenType := "refresh_token" if token == "" { token = strings.TrimSpace(accessToken) tokenType = "access_token" } if token == "" { return nil } form := url.Values{} form.Set("token", token) form.Set("token_type_hint", tokenType) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.revokeURL, strings.NewReader(form.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return errors.New("linear revoke failed") } return nil } func nodesFromPath(data map[string]interface{}, key string) []map[string]interface{} { container, _ := data[key].(map[string]interface{}) rawNodes, _ := container["nodes"].([]interface{}) nodes := make([]map[string]interface{}, 0, len(rawNodes)) for _, raw := range rawNodes { if item, ok := raw.(map[string]interface{}); ok { nodes = append(nodes, item) } } return nodes } func nestedString(data map[string]interface{}, keys ...string) string { var current interface{} = data for _, key := range keys { m, ok := current.(map[string]interface{}) if !ok { return "" } current = m[key] } if s, ok := current.(string); ok { return s } return "" } func graphqlInput(input map[string]interface{}) string { ordered := []string{"title", "teamId", "description", "assigneeId", "priority", "labelIds", "projectId", "stateId", "createAsUser", "displayIconUrl"} parts := make([]string, 0, len(input)) for _, key := range ordered { value, ok := input[key] if !ok || isBlankGraphQLValue(value) { continue } parts = append(parts, fmt.Sprintf("%s: %s", key, graphqlValue(value))) } return strings.Join(parts, ", ") } func isBlankGraphQLValue(value interface{}) bool { switch v := value.(type) { case nil: return true case string: return v == "" case []string: return len(v) == 0 default: return false } } func graphqlValue(value interface{}) string { switch v := value.(type) { case string: b, _ := json.Marshal(v) return string(b) case []string: items := make([]string, 0, len(v)) for _, item := range v { items = append(items, graphqlValue(item)) } return "[" + strings.Join(items, ", ") + "]" default: return fmt.Sprint(v) } } // ---- Notion Integration Service ---- // NotionIntegrationService implements Notion integration business logic. // Reference: Chatwoot Integrations::NotionController // Notion integration creates notes/pages from conversations. type NotionIntegrationService struct { hookRepo *repository.IntegrationHookRepo } // NotionAuthorizationResponse mirrors Chatwoot's Notion authorization payload. type NotionAuthorizationResponse struct { Success bool `json:"success"` URL string `json:"url,omitempty"` } // NewNotionIntegrationService creates a new NotionIntegrationService. func NewNotionIntegrationService(hookRepo *repository.IntegrationHookRepo) *NotionIntegrationService { return &NotionIntegrationService{hookRepo: hookRepo} } // BuildAuthorizationURL returns the Notion OAuth authorize URL for an account. func (s *NotionIntegrationService) BuildAuthorizationURL(accountID uint) (*NotionAuthorizationResponse, error) { clientID := strings.TrimSpace(os.Getenv("NOTION_CLIENT_ID")) clientSecret := strings.TrimSpace(os.Getenv("NOTION_CLIENT_SECRET")) if clientID == "" || clientSecret == "" { return nil, fmt.Errorf("Notion OAuth is not configured") } token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": accountID, "iat": time.Now().Unix(), }) state, err := token.SignedString([]byte(clientSecret)) if err != nil { return nil, fmt.Errorf("failed to generate Notion state: %w", err) } frontendURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/") if frontendURL == "" { frontendURL = "http://localhost:3000" } params := url.Values{} params.Set("client_id", clientID) params.Set("owner", "user") params.Set("redirect_uri", frontendURL+"/notion/callback") params.Set("response_type", "code") params.Set("state", state) return &NotionAuthorizationResponse{Success: true, URL: "https://api.notion.com/v1/oauth/authorize?" + params.Encode()}, nil } // Delete removes a Notion integration hook for an account. func (s *NotionIntegrationService) Delete(ctx context.Context, accountID uint) error { hooks, err := s.findNotionHooks(ctx, accountID) if err != nil || len(hooks) == 0 { return fmt.Errorf("Notion 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 Notion integration: %w", err) } } applogger.L().Infof("Notion integration deleted: account=%d", accountID) return nil } func (s *NotionIntegrationService) findNotionHooks(ctx context.Context, accountID uint) ([]model.IntegrationHook, error) { hooks, err := s.hookRepo.FindByAccountAndApp(ctx, accountID, "notion") if err != nil { return nil, err } if len(hooks) > 0 { return hooks, nil } return s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeNotion) }