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>
549 lines
17 KiB
Go
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)
|
|
}
|