package matrix import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/google/uuid" ) // MatrixService handles Matrix homeserver communication type MatrixService struct { homeserverURL string accessToken string serverName string httpClient *http.Client } // Config holds Matrix service configuration type Config struct { HomeserverURL string // e.g., "http://synapse:8008" AccessToken string // Admin/bot access token ServerName string // e.g., "breakpilot.local" } // NewMatrixService creates a new Matrix service instance func NewMatrixService(cfg Config) *MatrixService { return &MatrixService{ homeserverURL: cfg.HomeserverURL, accessToken: cfg.AccessToken, serverName: cfg.ServerName, httpClient: &http.Client{ Timeout: 30 * time.Second, }, } } // ======================================== // Matrix API Types // ======================================== // CreateRoomRequest represents a Matrix room creation request type CreateRoomRequest struct { Name string `json:"name,omitempty"` RoomAliasName string `json:"room_alias_name,omitempty"` Topic string `json:"topic,omitempty"` Visibility string `json:"visibility,omitempty"` // "private" or "public" Preset string `json:"preset,omitempty"` // "private_chat", "public_chat", "trusted_private_chat" IsDirect bool `json:"is_direct,omitempty"` Invite []string `json:"invite,omitempty"` InitialState []StateEvent `json:"initial_state,omitempty"` PowerLevelContentOverride *PowerLevels `json:"power_level_content_override,omitempty"` } // CreateRoomResponse represents a Matrix room creation response type CreateRoomResponse struct { RoomID string `json:"room_id"` } // StateEvent represents a Matrix state event type StateEvent struct { Type string `json:"type"` StateKey string `json:"state_key"` Content interface{} `json:"content"` } // PowerLevels represents Matrix power levels type PowerLevels struct { Ban int `json:"ban,omitempty"` Events map[string]int `json:"events,omitempty"` EventsDefault int `json:"events_default,omitempty"` Invite int `json:"invite,omitempty"` Kick int `json:"kick,omitempty"` Redact int `json:"redact,omitempty"` StateDefault int `json:"state_default,omitempty"` Users map[string]int `json:"users,omitempty"` UsersDefault int `json:"users_default,omitempty"` } // SendMessageRequest represents a message to send type SendMessageRequest struct { MsgType string `json:"msgtype"` Body string `json:"body"` Format string `json:"format,omitempty"` FormattedBody string `json:"formatted_body,omitempty"` } // UserInfo represents Matrix user information type UserInfo struct { UserID string `json:"user_id"` DisplayName string `json:"displayname,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` } // RegisterRequest for user registration type RegisterRequest struct { Username string `json:"username"` Password string `json:"password,omitempty"` Admin bool `json:"admin,omitempty"` } // RegisterResponse for user registration type RegisterResponse struct { UserID string `json:"user_id"` AccessToken string `json:"access_token"` DeviceID string `json:"device_id"` } // InviteRequest for inviting a user to a room type InviteRequest struct { UserID string `json:"user_id"` } // JoinRequest for joining a room type JoinRequest struct { Reason string `json:"reason,omitempty"` } // ======================================== // Room Management // ======================================== // CreateRoom creates a new Matrix room func (s *MatrixService) CreateRoom(ctx context.Context, req CreateRoomRequest) (*CreateRoomResponse, error) { body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } resp, err := s.doRequest(ctx, "POST", "/_matrix/client/v3/createRoom", body) if err != nil { return nil, fmt.Errorf("failed to create room: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, s.parseError(resp) } var result CreateRoomResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } return &result, nil } // CreateClassInfoRoom creates a broadcast room for a class (teachers write, parents read) func (s *MatrixService) CreateClassInfoRoom(ctx context.Context, className string, schoolName string, teacherMatrixIDs []string) (*CreateRoomResponse, error) { // Set up power levels: teachers can write (50), parents read-only (0) users := make(map[string]int) for _, teacherID := range teacherMatrixIDs { users[teacherID] = 50 } req := CreateRoomRequest{ Name: fmt.Sprintf("%s - %s (Info)", className, schoolName), Topic: fmt.Sprintf("Info-Kanal für %s. Nur Lehrer können schreiben.", className), Visibility: "private", Preset: "private_chat", Invite: teacherMatrixIDs, PowerLevelContentOverride: &PowerLevels{ EventsDefault: 50, // Only power level 50+ can send messages UsersDefault: 0, // Parents get power level 0 by default Users: users, Invite: 50, Kick: 50, Ban: 50, Redact: 50, }, } return s.CreateRoom(ctx, req) } // CreateStudentDMRoom creates a direct message room for parent-teacher communication about a student func (s *MatrixService) CreateStudentDMRoom(ctx context.Context, studentName string, className string, teacherMatrixIDs []string, parentMatrixIDs []string) (*CreateRoomResponse, error) { allUsers := append(teacherMatrixIDs, parentMatrixIDs...) users := make(map[string]int) for _, id := range allUsers { users[id] = 50 // All can write } req := CreateRoomRequest{ Name: fmt.Sprintf("%s (%s) - Dialog", studentName, className), Topic: fmt.Sprintf("Kommunikation über %s", studentName), Visibility: "private", Preset: "trusted_private_chat", IsDirect: false, Invite: allUsers, InitialState: []StateEvent{ { Type: "m.room.encryption", StateKey: "", Content: map[string]string{ "algorithm": "m.megolm.v1.aes-sha2", }, }, }, PowerLevelContentOverride: &PowerLevels{ EventsDefault: 0, // Everyone can send messages UsersDefault: 50, Users: users, }, } return s.CreateRoom(ctx, req) } // CreateParentRepRoom creates a room for class teacher and parent representatives func (s *MatrixService) CreateParentRepRoom(ctx context.Context, className string, teacherMatrixIDs []string, parentRepMatrixIDs []string) (*CreateRoomResponse, error) { allUsers := append(teacherMatrixIDs, parentRepMatrixIDs...) users := make(map[string]int) for _, id := range allUsers { users[id] = 50 } req := CreateRoomRequest{ Name: fmt.Sprintf("%s - Elternvertreter", className), Topic: fmt.Sprintf("Kommunikation zwischen Lehrkräften und Elternvertretern der %s", className), Visibility: "private", Preset: "private_chat", Invite: allUsers, PowerLevelContentOverride: &PowerLevels{ EventsDefault: 0, UsersDefault: 50, Users: users, }, } return s.CreateRoom(ctx, req) } // ======================================== // User Management // ======================================== // RegisterUser registers a new Matrix user (requires admin token) func (s *MatrixService) RegisterUser(ctx context.Context, username string, displayName string) (*RegisterResponse, error) { // Use admin API for user registration req := map[string]interface{}{ "username": username, "password": uuid.New().String(), // Generate random password "admin": false, } body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } // Use admin registration endpoint resp, err := s.doRequest(ctx, "POST", "/_synapse/admin/v2/users/@"+username+":"+s.serverName, body) if err != nil { return nil, fmt.Errorf("failed to register user: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return nil, s.parseError(resp) } // Set display name if displayName != "" { if err := s.SetDisplayName(ctx, "@"+username+":"+s.serverName, displayName); err != nil { // Log but don't fail fmt.Printf("Warning: failed to set display name: %v\n", err) } } return &RegisterResponse{ UserID: "@" + username + ":" + s.serverName, }, nil } // SetDisplayName sets the display name for a user func (s *MatrixService) SetDisplayName(ctx context.Context, userID string, displayName string) error { req := map[string]string{ "displayname": displayName, } body, err := json.Marshal(req) if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } endpoint := fmt.Sprintf("/_matrix/client/v3/profile/%s/displayname", url.PathEscape(userID)) resp, err := s.doRequest(ctx, "PUT", endpoint, body) if err != nil { return fmt.Errorf("failed to set display name: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return s.parseError(resp) } return nil } // ======================================== // Room Membership // ======================================== // InviteUser invites a user to a room func (s *MatrixService) InviteUser(ctx context.Context, roomID string, userID string) error { req := InviteRequest{UserID: userID} body, err := json.Marshal(req) if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/invite", url.PathEscape(roomID)) resp, err := s.doRequest(ctx, "POST", endpoint, body) if err != nil { return fmt.Errorf("failed to invite user: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return s.parseError(resp) } return nil } // JoinRoom makes the bot join a room func (s *MatrixService) JoinRoom(ctx context.Context, roomIDOrAlias string) error { endpoint := fmt.Sprintf("/_matrix/client/v3/join/%s", url.PathEscape(roomIDOrAlias)) resp, err := s.doRequest(ctx, "POST", endpoint, []byte("{}")) if err != nil { return fmt.Errorf("failed to join room: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return s.parseError(resp) } return nil } // SetUserPowerLevel sets a user's power level in a room func (s *MatrixService) SetUserPowerLevel(ctx context.Context, roomID string, userID string, powerLevel int) error { // First, get current power levels endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/state/m.room.power_levels/", url.PathEscape(roomID)) resp, err := s.doRequest(ctx, "GET", endpoint, nil) if err != nil { return fmt.Errorf("failed to get power levels: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return s.parseError(resp) } var powerLevels PowerLevels if err := json.NewDecoder(resp.Body).Decode(&powerLevels); err != nil { return fmt.Errorf("failed to decode power levels: %w", err) } // Update user power level if powerLevels.Users == nil { powerLevels.Users = make(map[string]int) } powerLevels.Users[userID] = powerLevel // Send updated power levels body, err := json.Marshal(powerLevels) if err != nil { return fmt.Errorf("failed to marshal power levels: %w", err) } resp2, err := s.doRequest(ctx, "PUT", endpoint, body) if err != nil { return fmt.Errorf("failed to set power levels: %w", err) } defer resp2.Body.Close() if resp2.StatusCode != http.StatusOK { return s.parseError(resp2) } return nil } // ======================================== // Messaging // ======================================== // SendMessage sends a text message to a room func (s *MatrixService) SendMessage(ctx context.Context, roomID string, message string) error { req := SendMessageRequest{ MsgType: "m.text", Body: message, } return s.sendEvent(ctx, roomID, "m.room.message", req) } // SendHTMLMessage sends an HTML-formatted message to a room func (s *MatrixService) SendHTMLMessage(ctx context.Context, roomID string, plainText string, htmlBody string) error { req := SendMessageRequest{ MsgType: "m.text", Body: plainText, Format: "org.matrix.custom.html", FormattedBody: htmlBody, } return s.sendEvent(ctx, roomID, "m.room.message", req) } // SendAbsenceNotification sends an absence notification to parents func (s *MatrixService) SendAbsenceNotification(ctx context.Context, roomID string, studentName string, date string, lessonNumber int) error { plainText := fmt.Sprintf("⚠️ Abwesenheitsmeldung\n\nIhr Kind %s war heute (%s) in der %d. Stunde nicht im Unterricht anwesend.\n\nBitte bestätigen Sie den Grund der Abwesenheit.", studentName, date, lessonNumber) htmlBody := fmt.Sprintf(`

⚠️ Abwesenheitsmeldung

Ihr Kind %s war heute (%s) in der %d. Stunde nicht im Unterricht anwesend.

Bitte bestätigen Sie den Grund der Abwesenheit.

`, studentName, date, lessonNumber) return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody) } // SendGradeNotification sends a grade notification to parents func (s *MatrixService) SendGradeNotification(ctx context.Context, roomID string, studentName string, subject string, gradeType string, grade float64) error { plainText := fmt.Sprintf("📊 Neue Note eingetragen\n\nFür %s wurde eine neue Note eingetragen:\n\nFach: %s\nArt: %s\nNote: %.1f", studentName, subject, gradeType, grade) htmlBody := fmt.Sprintf(`

📊 Neue Note eingetragen

Für %s wurde eine neue Note eingetragen:

Fach:%s
Art:%s
Note:%.1f
`, studentName, subject, gradeType, grade) return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody) } // SendClassAnnouncement sends an announcement to a class info room func (s *MatrixService) SendClassAnnouncement(ctx context.Context, roomID string, title string, content string, teacherName string) error { plainText := fmt.Sprintf("📢 %s\n\n%s\n\n— %s", title, content, teacherName) htmlBody := fmt.Sprintf(`

📢 %s

%s

— %s

`, title, content, teacherName) return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody) } // ======================================== // Internal Helpers // ======================================== func (s *MatrixService) sendEvent(ctx context.Context, roomID string, eventType string, content interface{}) error { body, err := json.Marshal(content) if err != nil { return fmt.Errorf("failed to marshal content: %w", err) } txnID := uuid.New().String() endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/%s/%s", url.PathEscape(roomID), url.PathEscape(eventType), txnID) resp, err := s.doRequest(ctx, "PUT", endpoint, body) if err != nil { return fmt.Errorf("failed to send event: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return s.parseError(resp) } return nil } func (s *MatrixService) doRequest(ctx context.Context, method string, endpoint string, body []byte) (*http.Response, error) { fullURL := s.homeserverURL + endpoint var bodyReader io.Reader if body != nil { bodyReader = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+s.accessToken) req.Header.Set("Content-Type", "application/json") return s.httpClient.Do(req) } func (s *MatrixService) parseError(resp *http.Response) error { body, _ := io.ReadAll(resp.Body) var errResp struct { ErrCode string `json:"errcode"` Error string `json:"error"` } if err := json.Unmarshal(body, &errResp); err != nil { return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body)) } return fmt.Errorf("matrix error %s: %s", errResp.ErrCode, errResp.Error) } // ======================================== // Health Check // ======================================== // HealthCheck checks if the Matrix server is reachable func (s *MatrixService) HealthCheck(ctx context.Context) error { resp, err := s.doRequest(ctx, "GET", "/_matrix/client/versions", nil) if err != nil { return fmt.Errorf("matrix server unreachable: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("matrix server returned status %d", resp.StatusCode) } return nil } // GetServerName returns the configured server name func (s *MatrixService) GetServerName() string { return s.serverName } // GenerateUserID generates a Matrix user ID from a username func (s *MatrixService) GenerateUserID(username string) string { return fmt.Sprintf("@%s:%s", username, s.serverName) }