This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/consent-service/internal/services/matrix/matrix_service.go
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

549 lines
17 KiB
Go

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(`<h3>⚠️ Abwesenheitsmeldung</h3>
<p>Ihr Kind <strong>%s</strong> war heute (%s) in der <strong>%d. Stunde</strong> nicht im Unterricht anwesend.</p>
<p>Bitte bestätigen Sie den Grund der Abwesenheit.</p>
<ul>
<li>✅ Entschuldigt (Krankheit)</li>
<li>📋 Arztbesuch</li>
<li>❓ Sonstiges (bitte erläutern)</li>
</ul>`, 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(`<h3>📊 Neue Note eingetragen</h3>
<p>Für <strong>%s</strong> wurde eine neue Note eingetragen:</p>
<table>
<tr><td>Fach:</td><td><strong>%s</strong></td></tr>
<tr><td>Art:</td><td>%s</td></tr>
<tr><td>Note:</td><td><strong>%.1f</strong></td></tr>
</table>`, 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(`<h3>📢 %s</h3>
<p>%s</p>
<p><em>— %s</em></p>`, 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)
}