Files
gochat/internal/service/dyte_integration_service.go
T

330 lines
10 KiB
Go

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
}