Files
gochat/internal/service/linear_notion_integration_service.go
T

667 lines
22 KiB
Go

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)
}