package jitsi import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "github.com/google/uuid" ) // JitsiService handles Jitsi Meet integration for video conferences type JitsiService struct { baseURL string appID string appSecret string httpClient *http.Client } // Config holds Jitsi service configuration type Config struct { BaseURL string // e.g., "http://localhost:8443" AppID string // Application ID for JWT (optional) AppSecret string // Secret for JWT signing (optional) } // NewJitsiService creates a new Jitsi service instance func NewJitsiService(cfg Config) *JitsiService { return &JitsiService{ baseURL: strings.TrimSuffix(cfg.BaseURL, "/"), appID: cfg.AppID, appSecret: cfg.AppSecret, httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } // ======================================== // Types // ======================================== // Meeting represents a Jitsi meeting configuration type Meeting struct { RoomName string `json:"room_name"` DisplayName string `json:"display_name,omitempty"` Email string `json:"email,omitempty"` Avatar string `json:"avatar,omitempty"` Subject string `json:"subject,omitempty"` Password string `json:"password,omitempty"` StartTime *time.Time `json:"start_time,omitempty"` Duration int `json:"duration,omitempty"` // in minutes Config *MeetingConfig `json:"config,omitempty"` Moderator bool `json:"moderator,omitempty"` Features *MeetingFeatures `json:"features,omitempty"` } // MeetingConfig holds Jitsi room configuration options type MeetingConfig struct { StartWithAudioMuted bool `json:"start_with_audio_muted,omitempty"` StartWithVideoMuted bool `json:"start_with_video_muted,omitempty"` DisableDeepLinking bool `json:"disable_deep_linking,omitempty"` RequireDisplayName bool `json:"require_display_name,omitempty"` EnableLobby bool `json:"enable_lobby,omitempty"` EnableRecording bool `json:"enable_recording,omitempty"` } // MeetingFeatures controls which features are enabled type MeetingFeatures struct { Livestreaming bool `json:"livestreaming,omitempty"` Recording bool `json:"recording,omitempty"` Transcription bool `json:"transcription,omitempty"` OutboundCall bool `json:"outbound_call,omitempty"` } // MeetingLink contains the generated meeting URL and metadata type MeetingLink struct { URL string `json:"url"` RoomName string `json:"room_name"` JoinURL string `json:"join_url"` ModeratorURL string `json:"moderator_url,omitempty"` Password string `json:"password,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` JWT string `json:"jwt,omitempty"` } // JWTClaims represents the JWT payload for Jitsi type JWTClaims struct { Audience string `json:"aud,omitempty"` Issuer string `json:"iss,omitempty"` Subject string `json:"sub,omitempty"` Room string `json:"room,omitempty"` ExpiresAt int64 `json:"exp,omitempty"` NotBefore int64 `json:"nbf,omitempty"` Context *JWTContext `json:"context,omitempty"` Moderator bool `json:"moderator,omitempty"` Features *JWTFeatures `json:"features,omitempty"` } // JWTContext contains user information for JWT type JWTContext struct { User *JWTUser `json:"user,omitempty"` Group string `json:"group,omitempty"` Callee *JWTCallee `json:"callee,omitempty"` } // JWTUser represents user info in JWT type JWTUser struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Email string `json:"email,omitempty"` Avatar string `json:"avatar,omitempty"` Moderator bool `json:"moderator,omitempty"` HiddenFromRecorder bool `json:"hidden-from-recorder,omitempty"` } // JWTCallee represents callee info (for 1:1 calls) type JWTCallee struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Avatar string `json:"avatar,omitempty"` } // JWTFeatures controls JWT-based feature access type JWTFeatures struct { Livestreaming string `json:"livestreaming,omitempty"` // "true" or "false" Recording string `json:"recording,omitempty"` Transcription string `json:"transcription,omitempty"` OutboundCall string `json:"outbound-call,omitempty"` } // ScheduledMeeting represents a scheduled training/meeting type ScheduledMeeting struct { ID string `json:"id"` Title string `json:"title"` Description string `json:"description,omitempty"` RoomName string `json:"room_name"` HostID string `json:"host_id"` HostName string `json:"host_name"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` Duration int `json:"duration"` // in minutes Password string `json:"password,omitempty"` MaxParticipants int `json:"max_participants,omitempty"` Features *MeetingFeatures `json:"features,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // ======================================== // Meeting Management // ======================================== // CreateMeetingLink generates a meeting URL with optional JWT authentication func (s *JitsiService) CreateMeetingLink(ctx context.Context, meeting Meeting) (*MeetingLink, error) { // Generate room name if not provided roomName := meeting.RoomName if roomName == "" { roomName = s.generateRoomName() } // Sanitize room name (Jitsi-compatible) roomName = s.sanitizeRoomName(roomName) link := &MeetingLink{ RoomName: roomName, URL: fmt.Sprintf("%s/%s", s.baseURL, roomName), JoinURL: fmt.Sprintf("%s/%s", s.baseURL, roomName), Password: meeting.Password, } // Generate JWT if authentication is configured if s.appSecret != "" { jwt, expiresAt, err := s.generateJWT(meeting, roomName) if err != nil { return nil, fmt.Errorf("failed to generate JWT: %w", err) } link.JWT = jwt link.ExpiresAt = expiresAt link.JoinURL = fmt.Sprintf("%s/%s?jwt=%s", s.baseURL, roomName, jwt) // Generate moderator URL if user is moderator if meeting.Moderator { link.ModeratorURL = link.JoinURL } } // Add config parameters to URL if meeting.Config != nil { params := s.buildConfigParams(meeting.Config) if params != "" { separator := "?" if strings.Contains(link.JoinURL, "?") { separator = "&" } link.JoinURL += separator + params } } return link, nil } // CreateTrainingSession creates a meeting link optimized for training sessions func (s *JitsiService) CreateTrainingSession(ctx context.Context, title string, hostName string, hostEmail string, duration int) (*MeetingLink, error) { meeting := Meeting{ RoomName: s.generateTrainingRoomName(title), DisplayName: hostName, Email: hostEmail, Subject: title, Duration: duration, Moderator: true, Config: &MeetingConfig{ StartWithAudioMuted: true, // Participants start muted StartWithVideoMuted: false, // Video on for training RequireDisplayName: true, // Know who's attending EnableLobby: true, // Waiting room EnableRecording: true, // Allow recording }, Features: &MeetingFeatures{ Recording: true, Transcription: false, }, } return s.CreateMeetingLink(ctx, meeting) } // CreateQuickMeeting creates a simple ad-hoc meeting func (s *JitsiService) CreateQuickMeeting(ctx context.Context, displayName string) (*MeetingLink, error) { meeting := Meeting{ DisplayName: displayName, Config: &MeetingConfig{ StartWithAudioMuted: false, StartWithVideoMuted: false, }, } return s.CreateMeetingLink(ctx, meeting) } // CreateParentTeacherMeeting creates a meeting for parent-teacher conferences func (s *JitsiService) CreateParentTeacherMeeting(ctx context.Context, teacherName string, parentName string, studentName string, scheduledTime time.Time) (*MeetingLink, error) { roomName := fmt.Sprintf("elterngespraech-%s-%s", s.sanitizeRoomName(studentName), scheduledTime.Format("20060102-1504")) meeting := Meeting{ RoomName: roomName, DisplayName: teacherName, Subject: fmt.Sprintf("Elterngespräch: %s", studentName), StartTime: &scheduledTime, Duration: 30, // 30 minutes default Moderator: true, Password: s.generatePassword(), Config: &MeetingConfig{ StartWithAudioMuted: false, StartWithVideoMuted: false, RequireDisplayName: true, EnableLobby: true, // Teacher admits parent DisableDeepLinking: true, }, } return s.CreateMeetingLink(ctx, meeting) } // CreateClassMeeting creates a meeting for an entire class func (s *JitsiService) CreateClassMeeting(ctx context.Context, className string, teacherName string, subject string) (*MeetingLink, error) { roomName := fmt.Sprintf("klasse-%s-%s", s.sanitizeRoomName(className), time.Now().Format("20060102")) meeting := Meeting{ RoomName: roomName, DisplayName: teacherName, Subject: fmt.Sprintf("%s - %s", className, subject), Moderator: true, Config: &MeetingConfig{ StartWithAudioMuted: true, // Students muted by default StartWithVideoMuted: false, RequireDisplayName: true, EnableLobby: false, // Direct join for classes }, } return s.CreateMeetingLink(ctx, meeting) } // ======================================== // JWT Generation // ======================================== // generateJWT creates a signed JWT for Jitsi authentication func (s *JitsiService) generateJWT(meeting Meeting, roomName string) (string, *time.Time, error) { if s.appSecret == "" { return "", nil, fmt.Errorf("app secret not configured") } now := time.Now() // Default expiration: 24 hours or based on meeting duration expiration := now.Add(24 * time.Hour) if meeting.Duration > 0 { expiration = now.Add(time.Duration(meeting.Duration+30) * time.Minute) } if meeting.StartTime != nil { expiration = meeting.StartTime.Add(time.Duration(meeting.Duration+60) * time.Minute) } claims := JWTClaims{ Audience: "jitsi", Issuer: s.appID, Subject: "meet.jitsi", Room: roomName, ExpiresAt: expiration.Unix(), NotBefore: now.Add(-5 * time.Minute).Unix(), // 5 min grace period Moderator: meeting.Moderator, Context: &JWTContext{ User: &JWTUser{ ID: uuid.New().String(), Name: meeting.DisplayName, Email: meeting.Email, Avatar: meeting.Avatar, Moderator: meeting.Moderator, }, }, } // Add features if specified if meeting.Features != nil { claims.Features = &JWTFeatures{ Recording: boolToString(meeting.Features.Recording), Livestreaming: boolToString(meeting.Features.Livestreaming), Transcription: boolToString(meeting.Features.Transcription), OutboundCall: boolToString(meeting.Features.OutboundCall), } } // Create JWT token, err := s.signJWT(claims) if err != nil { return "", nil, err } return token, &expiration, nil } // signJWT creates and signs a JWT token func (s *JitsiService) signJWT(claims JWTClaims) (string, error) { // Header header := map[string]string{ "alg": "HS256", "typ": "JWT", } headerJSON, err := json.Marshal(header) if err != nil { return "", err } // Payload payloadJSON, err := json.Marshal(claims) if err != nil { return "", err } // Encode headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) // Sign message := headerB64 + "." + payloadB64 h := hmac.New(sha256.New, []byte(s.appSecret)) h.Write([]byte(message)) signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) return message + "." + signature, nil } // ======================================== // Health Check // ======================================== // HealthCheck verifies the Jitsi server is accessible func (s *JitsiService) HealthCheck(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, "GET", s.baseURL, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } resp, err := s.httpClient.Do(req) if err != nil { return fmt.Errorf("jitsi server unreachable: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 500 { return fmt.Errorf("jitsi server error: status %d", resp.StatusCode) } return nil } // GetServerInfo returns information about the Jitsi server func (s *JitsiService) GetServerInfo() map[string]string { return map[string]string{ "base_url": s.baseURL, "app_id": s.appID, "auth_enabled": boolToString(s.appSecret != ""), } } // ======================================== // URL Building // ======================================== // BuildEmbedURL creates an embeddable iframe URL func (s *JitsiService) BuildEmbedURL(roomName string, displayName string, config *MeetingConfig) string { params := url.Values{} if displayName != "" { params.Set("userInfo.displayName", displayName) } if config != nil { if config.StartWithAudioMuted { params.Set("config.startWithAudioMuted", "true") } if config.StartWithVideoMuted { params.Set("config.startWithVideoMuted", "true") } if config.DisableDeepLinking { params.Set("config.disableDeepLinking", "true") } } embedURL := fmt.Sprintf("%s/%s", s.baseURL, s.sanitizeRoomName(roomName)) if len(params) > 0 { embedURL += "#" + params.Encode() } return embedURL } // BuildIFrameCode generates HTML iframe code for embedding func (s *JitsiService) BuildIFrameCode(roomName string, width int, height int) string { if width == 0 { width = 800 } if height == 0 { height = 600 } return fmt.Sprintf( ``, s.baseURL, s.sanitizeRoomName(roomName), width, height, ) } // ======================================== // Helper Functions // ======================================== // generateRoomName creates a unique room name func (s *JitsiService) generateRoomName() string { return fmt.Sprintf("breakpilot-%s", uuid.New().String()[:8]) } // generateTrainingRoomName creates a room name for training sessions func (s *JitsiService) generateTrainingRoomName(title string) string { sanitized := s.sanitizeRoomName(title) if sanitized == "" { sanitized = "schulung" } return fmt.Sprintf("%s-%s", sanitized, time.Now().Format("20060102-1504")) } // sanitizeRoomName removes invalid characters from room names func (s *JitsiService) sanitizeRoomName(name string) string { // Replace spaces and special characters result := strings.ToLower(name) result = strings.ReplaceAll(result, " ", "-") result = strings.ReplaceAll(result, "ä", "ae") result = strings.ReplaceAll(result, "ö", "oe") result = strings.ReplaceAll(result, "ü", "ue") result = strings.ReplaceAll(result, "ß", "ss") // Remove any remaining non-alphanumeric characters except hyphen var cleaned strings.Builder for _, r := range result { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { cleaned.WriteRune(r) } } // Remove consecutive hyphens result = cleaned.String() for strings.Contains(result, "--") { result = strings.ReplaceAll(result, "--", "-") } // Trim hyphens from start and end result = strings.Trim(result, "-") // Limit length if len(result) > 50 { result = result[:50] } return result } // generatePassword creates a random meeting password func (s *JitsiService) generatePassword() string { return uuid.New().String()[:8] } // buildConfigParams creates URL parameters from config func (s *JitsiService) buildConfigParams(config *MeetingConfig) string { params := url.Values{} if config.StartWithAudioMuted { params.Set("config.startWithAudioMuted", "true") } if config.StartWithVideoMuted { params.Set("config.startWithVideoMuted", "true") } if config.DisableDeepLinking { params.Set("config.disableDeepLinking", "true") } if config.RequireDisplayName { params.Set("config.requireDisplayName", "true") } if config.EnableLobby { params.Set("config.enableLobby", "true") } return params.Encode() } // boolToString converts bool to "true"/"false" string func boolToString(b bool) string { if b { return "true" } return "false" } // GetBaseURL returns the configured base URL func (s *JitsiService) GetBaseURL() string { return s.baseURL } // IsAuthEnabled returns whether JWT authentication is configured func (s *JitsiService) IsAuthEnabled() bool { return s.appSecret != "" }