package service import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "os" "strings" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "gorm.io/datatypes" "gorm.io/gorm" ) const dyteBaseURL = "https://api.dyte.io/v2" type DyteIntegrationService struct { hookRepo *repository.IntegrationHookRepo messageRepo *repository.MessageRepo backend DyteBackend frontendURL string } type DyteCredentials struct { OrganizationID string APIKey string } type DyteParticipant struct { ID uint Name string AvatarURL string } type DyteAPIError struct { Payload map[string]any Status int } type DyteBackend interface { CreateMeeting(ctx context.Context, credentials DyteCredentials, title string) (map[string]any, *DyteAPIError, error) AddParticipant(ctx context.Context, credentials DyteCredentials, meetingID string, participant DyteParticipant) (map[string]any, *DyteAPIError, error) } type HTTPDyteBackend struct { baseURL string client *http.Client } func NewHTTPDyteBackend() *HTTPDyteBackend { return &HTTPDyteBackend{ baseURL: dyteBaseURL, client: &http.Client{Timeout: 10 * time.Second}, } } func NewDyteIntegrationService(hookRepo *repository.IntegrationHookRepo, messageRepo *repository.MessageRepo) *DyteIntegrationService { return &DyteIntegrationService{ hookRepo: hookRepo, messageRepo: messageRepo, backend: NewHTTPDyteBackend(), } } func (s *DyteIntegrationService) SetBackend(backend DyteBackend) { if backend != nil { s.backend = backend } } func (s *DyteIntegrationService) SetFrontendURL(frontendURL string) { s.frontendURL = strings.TrimRight(frontendURL, "/") } func (s *DyteIntegrationService) DB() *gorm.DB { if s == nil || s.messageRepo == nil { return nil } return s.messageRepo.DB() } func (s *DyteIntegrationService) CreateMeeting(ctx context.Context, accountID, userID, conversationDisplayID uint, role string) (*model.Message, *model.Conversation, *DyteAPIError, error) { conversation, err := s.findConversation(ctx, accountID, conversationDisplayID) if err != nil { return nil, nil, nil, err } if !s.canAccessConversation(ctx, accountID, conversation.InboxID, userID, role) { return nil, nil, nil, fmt.Errorf("forbidden") } user, err := s.findUser(ctx, userID) if err != nil { return nil, nil, nil, err } credentials, err := s.credentials(ctx, accountID) if err != nil { return nil, nil, nil, err } title := fmt.Sprintf("%s has started a meeting", availableUserName(user)) meeting, apiErr, err := s.backend.CreateMeeting(ctx, credentials, title) if err != nil || apiErr != nil { return nil, conversation, apiErr, err } meetingID, _ := meeting["id"].(string) if strings.TrimSpace(meetingID) == "" { return nil, conversation, nil, fmt.Errorf("dyte meeting id missing") } attrs := map[string]any{"type": "dyte", "data": map[string]any{"meeting_id": meetingID}} attrsJSON, _ := json.Marshal(attrs) message := &model.Message{ AccountID: accountID, InboxID: conversation.InboxID, ConversationID: conversation.ID, SenderID: &user.ID, SenderType: "user", MessageType: string(model.MessageTypeOutgoing), ContentType: "integrations", Content: title, Status: "sent", ContentAttributes: datatypes.JSON(attrsJSON), } if err := s.messageRepo.Create(ctx, message); err != nil { return nil, conversation, nil, err } return message, conversation, nil, nil } func (s *DyteIntegrationService) AddParticipant(ctx context.Context, accountID, userID, messageID uint, role string) (map[string]any, *DyteAPIError, error) { message, err := s.messageRepo.FindByAccountAndID(ctx, accountID, messageID) if err != nil { return nil, nil, err } var conversation model.Conversation if err := s.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, message.ConversationID).First(&conversation).Error; err != nil { return nil, nil, err } if !s.canAccessConversation(ctx, accountID, conversation.InboxID, userID, role) { return nil, nil, fmt.Errorf("forbidden") } if message.ContentType != "integrations" { return nil, &DyteAPIError{Payload: map[string]any{"error": "Invalid message type. Action not permitted"}, Status: http.StatusUnprocessableEntity}, nil } meetingID := dyteMeetingID(message.ContentAttributes) if meetingID == "" { return nil, nil, fmt.Errorf("meeting_id is required") } user, err := s.findUser(ctx, userID) if err != nil { return nil, nil, err } credentials, err := s.credentials(ctx, accountID) if err != nil { return nil, nil, err } payload, apiErr, err := s.backend.AddParticipant(ctx, credentials, meetingID, DyteParticipant{ID: user.ID, Name: availableUserName(user), AvatarURL: s.avatarURL(user)}) if err != nil || apiErr != nil { return nil, apiErr, err } return payload, nil, nil } func (s *DyteIntegrationService) findConversation(ctx context.Context, accountID, displayID uint) (*model.Conversation, error) { var conversation model.Conversation db := s.DB().WithContext(ctx) if err := db.Where("account_id = ? AND display_id = ?", accountID, displayID).First(&conversation).Error; err == nil { return &conversation, nil } if err := db.Where("account_id = ? AND id = ?", accountID, displayID).First(&conversation).Error; err != nil { return nil, err } return &conversation, nil } func (s *DyteIntegrationService) findUser(ctx context.Context, userID uint) (*model.User, error) { if userID == 0 { return nil, fmt.Errorf("user not authenticated") } var user model.User if err := s.DB().WithContext(ctx).First(&user, userID).Error; err != nil { return nil, err } return &user, nil } func (s *DyteIntegrationService) canAccessConversation(ctx context.Context, accountID, inboxID, userID uint, role string) bool { if userID == 0 { return false } switch strings.ToLower(role) { case "administrator", "admin", "super_admin": return true } var member model.InboxMember return s.DB().WithContext(ctx). Joins("JOIN inboxes ON inboxes.id = inbox_members.inbox_id"). Where("inbox_members.inbox_id = ? AND inbox_members.user_id = ? AND inboxes.account_id = ?", inboxID, userID, accountID). First(&member).Error == nil } func (s *DyteIntegrationService) credentials(ctx context.Context, accountID uint) (DyteCredentials, error) { hooks, err := s.hookRepo.FindByAccountAndApp(ctx, accountID, "dyte") if err != nil { return DyteCredentials{}, err } if len(hooks) == 0 { return DyteCredentials{}, fmt.Errorf("dyte integration not found") } var settings map[string]any if err := json.Unmarshal(hooks[0].Settings, &settings); err != nil { return DyteCredentials{}, err } credentials := DyteCredentials{ OrganizationID: stringMapValue(settings, "organization_id"), APIKey: stringMapValue(settings, "api_key"), } if credentials.OrganizationID == "" || credentials.APIKey == "" { return DyteCredentials{}, fmt.Errorf("dyte credentials are required") } return credentials, nil } func (s *DyteIntegrationService) avatarURL(user *model.User) string { if user.AvatarURL != "" { return user.AvatarURL } base := s.frontendURL if base == "" { base = strings.TrimRight(os.Getenv("FRONTEND_URL"), "/") } if base == "" { base = "http://localhost:3000" } return base + "/integrations/slack/user.png" } func dyteMeetingID(attrs datatypes.JSON) string { var raw map[string]any if err := json.Unmarshal(attrs, &raw); err != nil { return "" } data, _ := raw["data"].(map[string]any) return stringMapValue(data, "meeting_id") } func availableUserName(user *model.User) string { if user == nil { return "" } if strings.TrimSpace(user.DisplayName) != "" { return strings.TrimSpace(user.DisplayName) } return strings.TrimSpace(user.Name) } func stringMapValue(values map[string]any, key string) string { if values == nil { return "" } switch value := values[key].(type) { case string: return strings.TrimSpace(value) case fmt.Stringer: return strings.TrimSpace(value.String()) default: return "" } } func (b *HTTPDyteBackend) CreateMeeting(ctx context.Context, credentials DyteCredentials, title string) (map[string]any, *DyteAPIError, error) { return b.post(ctx, credentials, "meetings", map[string]any{"title": title}) } func (b *HTTPDyteBackend) AddParticipant(ctx context.Context, credentials DyteCredentials, meetingID string, participant DyteParticipant) (map[string]any, *DyteAPIError, error) { if meetingID == "" || participant.ID == 0 || strings.TrimSpace(participant.Name) == "" || strings.TrimSpace(participant.AvatarURL) == "" { return nil, nil, errors.New("missing information") } payload := map[string]any{ "custom_participant_id": fmt.Sprintf("%d", participant.ID), "name": participant.Name, "picture": participant.AvatarURL, "preset_name": "group_call_host", } return b.post(ctx, credentials, "meetings/"+meetingID+"/participants", payload) } func (b *HTTPDyteBackend) post(ctx context.Context, credentials DyteCredentials, path string, payload map[string]any) (map[string]any, *DyteAPIError, error) { if credentials.OrganizationID == "" || credentials.APIKey == "" { return nil, nil, errors.New("missing credentials") } body, _ := json.Marshal(payload) baseURL := strings.TrimRight(b.baseURL, "/") req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/"+strings.TrimLeft(path, "/"), bytes.NewReader(body)) if err != nil { return nil, nil, err } auth := base64.StdEncoding.EncodeToString([]byte(credentials.OrganizationID + ":" + credentials.APIKey)) req.Header.Set("Authorization", "Basic "+auth) req.Header.Set("Content-Type", "application/json") client := b.client if client == nil { client = http.DefaultClient } resp, err := client.Do(req) if err != nil { return nil, nil, err } defer resp.Body.Close() var decoded map[string]any if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { decoded = map[string]any{"message": err.Error()} } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, &DyteAPIError{Payload: decoded, Status: resp.StatusCode}, nil } if data, ok := decoded["data"].(map[string]any); ok { return data, nil, nil } return decoded, nil, nil }