package main import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "sync" "time" "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto/cryptohelper" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) type MatrixClient struct { HomeserverURL string AccessToken string RoomID string Limit int TimeLocation *time.Location httpClient *http.Client crypto *cryptohelper.CryptoHelper cryptoClient *mautrix.Client syncOnce sync.Once } func newMatrixClientFromEnv() *MatrixClient { hs := strings.TrimRight(readEnv("MATRIX_HOMESERVER_URL", ""), "/") token := readEnv("MATRIX_ACCESS_TOKEN", "") roomID := readEnv("MATRIX_ROOM_ID", "") if hs == "" || token == "" || roomID == "" { return nil } limit := mustInt(readEnv("MATRIX_LIMIT", "25"), 25) if limit < 1 { limit = 1 } if limit > 100 { limit = 100 } m := &MatrixClient{ HomeserverURL: hs, AccessToken: token, RoomID: roomID, Limit: limit, httpClient: &http.Client{ Timeout: 8 * time.Second, }, } locName := strings.TrimSpace(readEnv("MATRIX_TIMEZONE", "Europe/Berlin")) if loc, err := time.LoadLocation(locName); err == nil { m.TimeLocation = loc } else { m.TimeLocation = time.Local } if readEnv("MATRIX_ENABLE_CRYPTO", "1") != "0" { m.crypto, m.cryptoClient = initMatrixCrypto(hs, token) m.startCryptoSync() } return m } func initMatrixCrypto(hs, token string) (*cryptohelper.CryptoHelper, *mautrix.Client) { cli, err := mautrix.NewClient(hs, "", token) if err != nil { return nil, nil } if whoami, err := cli.Whoami(context.Background()); err == nil && whoami != nil { cli.SetCredentials(whoami.UserID, token) cli.DeviceID = whoami.DeviceID } storePath := readEnv("MATRIX_CRYPTO_STORE_PATH", "/tmp/farmcal-matrix-crypto.db") pickleKey := []byte(readEnv("MATRIX_PICKLE_KEY", token)) helper, err := cryptohelper.NewCryptoHelper(cli, pickleKey, storePath) if err != nil { return nil, nil } if err = helper.Init(context.Background()); err != nil { return nil, nil } return helper, cli } func (m *MatrixClient) startCryptoSync() { if m.cryptoClient == nil || m.crypto == nil { return } m.syncOnce.Do(func() { go func() { _ = m.cryptoClient.SyncWithContext(context.Background()) }() }) } func (m *MatrixClient) FetchRecentMessages(ctx context.Context) ([]MatrixMessage, error) { endpoint := fmt.Sprintf( "%s/_matrix/client/v3/rooms/%s/messages?dir=b&limit=%d", m.HomeserverURL, url.PathEscape(m.RoomID), m.Limit, ) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+m.AccessToken) resp, err := m.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, errors.New("matrix messages failed: " + strconv.Itoa(resp.StatusCode)) } var messagesResp struct { Chunk []json.RawMessage `json:"chunk"` } if err := json.NewDecoder(resp.Body).Decode(&messagesResp); err != nil { return nil, err } if len(messagesResp.Chunk) == 0 { return []MatrixMessage{}, nil } out := make([]MatrixMessage, 0, len(messagesResp.Chunk)) for i := len(messagesResp.Chunk) - 1; i >= 0; i-- { ev := &event.Event{} if err := json.Unmarshal(messagesResp.Chunk[i], ev); err != nil { continue } ev.RoomID = id.RoomID(m.RoomID) msg, ok := m.mapEventToMessage(ctx, ev) if ok { out = append(out, msg) } } return out, nil } func (m *MatrixClient) mapEventToMessage(ctx context.Context, ev *event.Event) (MatrixMessage, bool) { ts := m.formatTimestamp(ev.Timestamp) sender := shortMatrixSender(string(ev.Sender)) switch ev.Type { case event.EventMessage: if body, ok := extractBody(ev); ok { return MatrixMessage{Sender: sender, Body: body, Timestamp: ts}, true } return MatrixMessage{}, false case event.EventEncrypted: if m.crypto != nil { decrypted, err := m.crypto.Decrypt(ctx, ev) if errors.Is(err, cryptohelper.NoSessionFound) { content := ev.Content.AsEncrypted() m.crypto.RequestSession(ctx, ev.RoomID, content.SenderKey, content.SessionID, ev.Sender, content.DeviceID) waitCtx, cancel := context.WithTimeout(ctx, 8*time.Second) if m.crypto.WaitForSession(waitCtx, ev.RoomID, content.SenderKey, content.SessionID, 7*time.Second) { decrypted, err = m.crypto.Decrypt(ctx, ev) } cancel() } if err == nil && decrypted != nil { if body, ok := extractBody(decrypted); ok { return MatrixMessage{Sender: sender, Body: body, Timestamp: ts}, true } } } return MatrixMessage{Sender: sender, Body: "[Verschlüsselte Nachricht]", Timestamp: ts}, true default: return MatrixMessage{}, false } } func (m *MatrixClient) formatTimestamp(tsMillis int64) string { loc := m.TimeLocation if loc == nil { loc = time.Local } return time.UnixMilli(tsMillis).In(loc).Format("02.01.2006 15:04") } func extractBody(ev *event.Event) (string, bool) { if ev == nil { return "", false } _ = ev.Content.ParseRaw(ev.Type) msg := ev.Content.AsMessage() if msg.MsgType != event.MsgText && msg.MsgType != event.MsgNotice { return "", false } body := strings.TrimSpace(msg.Body) if body == "" { return "", false } return body, true } func shortMatrixSender(sender string) string { s := strings.TrimSpace(sender) if s == "" { return sender } if strings.HasPrefix(s, "@") { s = strings.TrimPrefix(s, "@") } if idx := strings.Index(s, ":"); idx > 0 { return s[:idx] } return s }