330 lines
10 KiB
Go
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
|
|
}
|