diff --git a/.env.example b/.env.example index 1fdfb6c..4cf5980 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,9 @@ APP_PORT=8080 # Beispiel fuer externe MariaDB im Netzwerk: # DB_DSN=farmcal:DEIN_PASSWORT@tcp(192.168.178.20:3306)/farmcal?parseTime=true DB_DSN=farmcal:CHANGE_ME@tcp(127.0.0.1:3306)/farmcal?parseTime=true + +# Optional: Matrix-Chat (read-only) +# MATRIX_HOMESERVER_URL=https://matrix.example.com +# MATRIX_ACCESS_TOKEN=YOUR_MATRIX_ACCESS_TOKEN +# MATRIX_ROOM_ID=!abcdefg12345:example.com +# MATRIX_LIMIT=25 diff --git a/README.md b/README.md index 644a1b0..be7bba5 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,18 @@ APP_PORT=8080 DB_DSN=farmcal:SEHR_SICHERES_PASSWORT@tcp(192.168.1.40:3306)/farmcal?parseTime=true ``` +Optional fuer Matrix-Chat (nur Anzeige): + +```env +MATRIX_HOMESERVER_URL=https://matrix.example.com +MATRIX_ACCESS_TOKEN=YOUR_MATRIX_ACCESS_TOKEN +MATRIX_ROOM_ID=!abcdefg12345:example.com +MATRIX_LIMIT=25 +``` + +- `MATRIX_ACCESS_TOKEN` sollte ein User-Token mit Leserechten fuer den Raum sein. +- Wenn Matrix-Variablen fehlen, bleibt das Feature deaktiviert. + ## Start mit Docker Compose ```bash @@ -95,3 +107,4 @@ git push -u origin main - Kalenderansicht fuer mehrere Monate - Mehrspieler-/Benutzerverwaltung - Import/Export fuer bestehende Tabellen +- Matrix: Nachrichten aus FarmCal senden (write support) diff --git a/cmd/server/handlers_pages.go b/cmd/server/handlers_pages.go index 338cb71..aa8eec4 100644 --- a/cmd/server/handlers_pages.go +++ b/cmd/server/handlers_pages.go @@ -69,6 +69,16 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { Calendar: buildCalendar(plans, crops, products, stepMap, customTasks, doneMap, (settings.CurrentCycle*12)+(settings.CurrentMonth-1), settings.CurrentDay, settings.DaysPerMonth, 14), PlanningCount: len(plans), } + if a.matrix != nil { + data.MatrixEnabled = true + data.MatrixRoomID = a.matrix.RoomID + msgs, err := a.matrix.FetchRecentMessages(r.Context()) + if err != nil { + data.MatrixError = err.Error() + } else { + data.MatrixMessages = msgs + } + } data.TodayGroups = groupTasksByField(data.TodayTasks) a.renderTemplate(w, "templates/dashboard.html", data) } diff --git a/cmd/server/main.go b/cmd/server/main.go index c55411c..1c1e18f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -18,7 +18,8 @@ var monthNames = []string{ } type App struct { - db *sql.DB + db *sql.DB + matrix *MatrixClient } func main() { @@ -38,7 +39,10 @@ func main() { log.Fatalf("schema setup failed: %v", err) } - app := &App{db: db} + app := &App{ + db: db, + matrix: newMatrixClientFromEnv(), + } mux := http.NewServeMux() mux.HandleFunc("/", app.handleDashboard) diff --git a/cmd/server/matrix.go b/cmd/server/matrix.go new file mode 100644 index 0000000..8457748 --- /dev/null +++ b/cmd/server/matrix.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type MatrixClient struct { + HomeserverURL string + AccessToken string + RoomID string + Limit int + httpClient *http.Client +} + +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 + } + + return &MatrixClient{ + HomeserverURL: hs, + AccessToken: token, + RoomID: roomID, + Limit: limit, + httpClient: &http.Client{ + Timeout: 6 * time.Second, + }, + } +} + +func (m *MatrixClient) FetchRecentMessages(ctx context.Context) ([]MatrixMessage, error) { + filter := fmt.Sprintf(`{"room":{"rooms":["%s"],"timeline":{"limit":%d}}}`, m.RoomID, m.Limit) + endpoint := m.HomeserverURL + "/_matrix/client/v3/sync?timeout=0&filter=" + url.QueryEscape(filter) + + 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 sync failed: " + strconv.Itoa(resp.StatusCode)) + } + + var syncResp struct { + Rooms struct { + Join map[string]struct { + Timeline struct { + Events []struct { + Type string `json:"type"` + Sender string `json:"sender"` + OriginServerTS int64 `json:"origin_server_ts"` + Content struct { + MsgType string `json:"msgtype"` + Body string `json:"body"` + } `json:"content"` + } `json:"events"` + } `json:"timeline"` + } `json:"join"` + } `json:"rooms"` + } + if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil { + return nil, err + } + + room, ok := syncResp.Rooms.Join[m.RoomID] + if !ok { + return []MatrixMessage{}, nil + } + + out := make([]MatrixMessage, 0, len(room.Timeline.Events)) + for _, ev := range room.Timeline.Events { + if ev.Type != "m.room.message" { + continue + } + if ev.Content.MsgType != "m.text" && ev.Content.MsgType != "m.notice" { + continue + } + body := strings.TrimSpace(ev.Content.Body) + if body == "" { + continue + } + ts := time.UnixMilli(ev.OriginServerTS).Local().Format("02.01.2006 15:04") + out = append(out, MatrixMessage{ + Sender: ev.Sender, + Body: body, + Timestamp: ts, + }) + } + return out, nil +} diff --git a/cmd/server/types.go b/cmd/server/types.go index a7017ff..9039671 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -149,6 +149,10 @@ type DashboardPage struct { TodayGroups []FieldTaskGroup Calendar []CalendarMonth PlanningCount int + MatrixEnabled bool + MatrixRoomID string + MatrixError string + MatrixMessages []MatrixMessage } type FieldsPage struct { @@ -181,3 +185,9 @@ type GeneralPage struct { Settings Settings Months []MonthOption } + +type MatrixMessage struct { + Sender string + Body string + Timestamp string +} diff --git a/docker-compose.yml b/docker-compose.yml index ce0ccf5..321c15d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,3 +9,7 @@ services: environment: APP_ADDR: ":8080" DB_DSN: "${DB_DSN}" + MATRIX_HOMESERVER_URL: "${MATRIX_HOMESERVER_URL:-}" + MATRIX_ACCESS_TOKEN: "${MATRIX_ACCESS_TOKEN:-}" + MATRIX_ROOM_ID: "${MATRIX_ROOM_ID:-}" + MATRIX_LIMIT: "${MATRIX_LIMIT:-25}" diff --git a/static/styles.css b/static/styles.css index 67c1716..268ef55 100644 --- a/static/styles.css +++ b/static/styles.css @@ -250,6 +250,25 @@ button:hover { background: linear-gradient(180deg, #7cbc19, #5e8a12); } .month-days li { margin: .24rem 0; } +.matrix-list { + list-style: none; + margin: .5rem 0 0; + padding: 0; + display: grid; + gap: .5rem; + max-height: 280px; + overflow: auto; +} + +.matrix-list li { + border: 1px solid #d7decb; + border-radius: 9px; + background: #f9fdf2; + padding: .5rem .6rem; + display: grid; + gap: .2rem; +} + .table-wrap { overflow-x: auto; max-width: 100%; diff --git a/templates/dashboard.html b/templates/dashboard.html index 5608ae7..20b3aea 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -83,6 +83,30 @@ {{end}} + + Matrix-Chat + {{if .MatrixEnabled}} + Raum: {{.MatrixRoomID}} + {{if .MatrixError}} + Chat konnte nicht geladen werden: {{.MatrixError}} + {{else if .MatrixMessages}} + + {{range .MatrixMessages}} + + {{.Sender}} + {{.Body}} + {{.Timestamp}} + + {{end}} + + {{else}} + Keine Nachrichten gefunden. + {{end}} + {{else}} + Matrix-Chat ist nicht konfiguriert. + {{end}} + + Kalender ab jetzt (14 Monate)
Raum: {{.MatrixRoomID}}
{{.MatrixRoomID}}
Chat konnte nicht geladen werden: {{.MatrixError}}
Keine Nachrichten gefunden.
Matrix-Chat ist nicht konfiguriert.