257 lines
6.4 KiB
Go
257 lines
6.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
_ "time/tzdata"
|
|
|
|
"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 msg, ok := m.extractMessage(ev); ok {
|
|
msg.Sender = sender
|
|
msg.Timestamp = ts
|
|
return msg, 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 msg, ok := m.extractMessage(decrypted); ok {
|
|
msg.Sender = sender
|
|
msg.Timestamp = ts
|
|
return msg, 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 (m *MatrixClient) extractMessage(ev *event.Event) (MatrixMessage, bool) {
|
|
if ev == nil {
|
|
return MatrixMessage{}, false
|
|
}
|
|
_ = ev.Content.ParseRaw(ev.Type)
|
|
msg := ev.Content.AsMessage()
|
|
switch msg.MsgType {
|
|
case event.MsgText, event.MsgNotice:
|
|
body := strings.TrimSpace(msg.Body)
|
|
if body == "" {
|
|
return MatrixMessage{}, false
|
|
}
|
|
return MatrixMessage{Body: body}, true
|
|
case event.MsgImage:
|
|
imageURL := m.mxcToHTTP(string(msg.URL))
|
|
if imageURL == "" {
|
|
// Encrypted file attachments need additional media decryption path.
|
|
return MatrixMessage{Body: "[Bild konnte nicht direkt dargestellt werden]"}, true
|
|
}
|
|
body := strings.TrimSpace(msg.Body)
|
|
if body == "" {
|
|
body = "Bild"
|
|
}
|
|
return MatrixMessage{Body: body, ImageURL: imageURL}, true
|
|
default:
|
|
return MatrixMessage{}, false
|
|
}
|
|
}
|
|
|
|
func (m *MatrixClient) mxcToHTTP(mxc string) string {
|
|
raw := strings.TrimSpace(mxc)
|
|
if raw == "" || !strings.HasPrefix(raw, "mxc://") {
|
|
return ""
|
|
}
|
|
trimmed := strings.TrimPrefix(raw, "mxc://")
|
|
parts := strings.SplitN(trimmed, "/", 2)
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%s/_matrix/media/v3/download/%s/%s", m.HomeserverURL, url.PathEscape(parts[0]), url.PathEscape(parts[1]))
|
|
}
|
|
|
|
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
|
|
}
|