From 74a93e22bfd95250ab82ea9e51cbbd0d2acc0d13 Mon Sep 17 00:00:00 2001 From: doobry Date: Sat, 3 Jan 2026 16:24:38 +0100 Subject: [PATCH 1/9] feat: add matrix bridge support --- config.yml.dist | 5 + internal/api/api.go | 2 + internal/api/response/response.go | 1 + internal/api/response/ticker.go | 14 +++ internal/api/tickers.go | 51 ++++++++ internal/bridge/bridge.go | 3 +- internal/bridge/bridge_test.go | 2 +- internal/bridge/matrix_room.go | 34 ++++++ internal/config/config.go | 21 ++++ internal/matrix/matrix.go | 174 +++++++++++++++++++++++++++ internal/matrix/matrix_test.go | 87 ++++++++++++++ internal/storage/migrations.go | 1 + internal/storage/mock_Storage.go | 51 ++++++++ internal/storage/sql_storage.go | 10 ++ internal/storage/sql_storage_test.go | 1 + internal/storage/storage.go | 1 + internal/storage/ticker.go | 15 +++ 17 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 internal/bridge/matrix_room.go create mode 100644 internal/matrix/matrix.go create mode 100644 internal/matrix/matrix_test.go diff --git a/config.yml.dist b/config.yml.dist index 5169cfb4..c32245ae 100644 --- a/config.yml.dist +++ b/config.yml.dist @@ -18,6 +18,11 @@ signal_group: api_url: "" avatar: "" account: "" +# matrix configuration +matrix: + api_url: "" + token: "" + homeserver: "" # listen port for prometheus metrics exporter metrics_listen: ":8181" upload: diff --git a/internal/api/api.go b/internal/api/api.go index f6c0a4bf..4910c3d6 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -90,6 +90,8 @@ func API(config config.Config, store storage.Storage) *Server { admin.PUT(`/tickers/:tickerID/signal_group`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PutTickerSignalGroup) admin.DELETE(`/tickers/:tickerID/signal_group`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.DeleteTickerSignalGroup) admin.PUT(`/tickers/:tickerID/signal_group/admin`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PutTickerSignalGroupAdmin) + admin.PUT(`/tickers/:tickerID/matrix`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PutTickerMatrix) + admin.DELETE(`/tickers/:tickerID/matrix`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.DeleteTickerMatrix) admin.DELETE(`/tickers/:tickerID`, user.NeedAdmin(), ticker.PrefetchTicker(store), handler.DeleteTicker) admin.PUT(`/tickers/:tickerID/reset`, ticker.PrefetchTicker(store, storage.WithPreload()), ticker.PrefetchTicker(store), handler.ResetTicker) admin.GET(`/tickers/:tickerID/users`, ticker.PrefetchTicker(store), handler.GetTickerUsers) diff --git a/internal/api/response/response.go b/internal/api/response/response.go index 12492ef8..3e22293a 100644 --- a/internal/api/response/response.go +++ b/internal/api/response/response.go @@ -25,6 +25,7 @@ const ( BlueskyError ErrorMessage = "unable to connect to bluesky" SignalGroupError ErrorMessage = "unable to connect to signal" SignalGroupDeleteError ErrorMessage = "unable to delete signal group" + MatrixError ErrorMessage = "unable to connect to matrix" PasswordError ErrorMessage = "could not authenticate password" StatusSuccess Status = `success` diff --git a/internal/api/response/ticker.go b/internal/api/response/ticker.go index b4a1080f..980ceb02 100644 --- a/internal/api/response/ticker.go +++ b/internal/api/response/ticker.go @@ -19,6 +19,7 @@ type Ticker struct { Mastodon Mastodon `json:"mastodon"` Bluesky Bluesky `json:"bluesky"` SignalGroup SignalGroup `json:"signalGroup"` + Matrix Matrix `json:"matrix"` Location Location `json:"location"` } @@ -71,6 +72,13 @@ type SignalGroup struct { GroupInviteLink string `json:"groupInviteLink"` } +type Matrix struct { + Active bool `json:"active"` + Connected bool `json:"connected"` + RoomID string `json:"roomID"` + RoomName string `json:"roomName"` +} + type Location struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` @@ -130,6 +138,12 @@ func TickerResponse(t storage.Ticker, config config.Config) Ticker { GroupID: t.SignalGroup.GroupID, GroupInviteLink: t.SignalGroup.GroupInviteLink, }, + Matrix: Matrix{ + Active: t.Matrix.Active, + Connected: t.Matrix.Connected(), + RoomID: t.Matrix.RoomID, + RoomName: t.Matrix.RoomName, + }, Location: Location{ Lat: t.Location.Lat, Lon: t.Location.Lon, diff --git a/internal/api/tickers.go b/internal/api/tickers.go index 0af1e87d..a2360d80 100644 --- a/internal/api/tickers.go +++ b/internal/api/tickers.go @@ -11,6 +11,7 @@ import ( "github.com/systemli/ticker/internal/api/helper" "github.com/systemli/ticker/internal/api/response" "github.com/systemli/ticker/internal/bluesky" + "github.com/systemli/ticker/internal/matrix" "github.com/systemli/ticker/internal/signal" "github.com/systemli/ticker/internal/storage" ) @@ -486,6 +487,56 @@ func (h *handler) PutTickerSignalGroupAdmin(c *gin.Context) { c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{})) } +func (h *handler) PutTickerMatrix(c *gin.Context) { + ticker, err := helper.Ticker(c) + if err != nil { + c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound)) + return + } + + var body storage.TickerMatrix + err = c.Bind(&body) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeNotFound, response.FormError)) + return + } + + roomID, roomAliasName, err := matrix.CreateRoom(h.config, &ticker) + if err != nil { + log.WithError(err).Error("failed to create matrix room") + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.MatrixError)) + return + } + + ticker.Matrix.RoomID = roomID + ticker.Matrix.RoomName = roomAliasName + ticker.Matrix.Active = body.Active + + err = h.storage.SaveTicker(&ticker) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError)) + return + } + + c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"ticker": response.TickerResponse(ticker, h.config)})) +} + +func (h *handler) DeleteTickerMatrix(c *gin.Context) { + ticker, err := helper.Ticker(c) + if err != nil { + c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound)) + return + } + + err = h.storage.DeleteMatrix(&ticker) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError)) + return + } + + c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"ticker": response.TickerResponse(ticker, h.config)})) +} + func (h *handler) DeleteTicker(c *gin.Context) { ticker, err := helper.Ticker(c) if err != nil { diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go index b2466eb0..107aa3a3 100644 --- a/internal/bridge/bridge.go +++ b/internal/bridge/bridge.go @@ -21,8 +21,9 @@ func RegisterBridges(config config.Config, storage storage.Storage) Bridges { mastodon := MastodonBridge{config, storage} bluesky := BlueskyBridge{config, storage} signalGroup := SignalGroupBridge{config, storage} + matrix := MatrixBridge{config, storage} - return Bridges{"telegram": &telegram, "mastodon": &mastodon, "bluesky": &bluesky, "signalGroup": &signalGroup} + return Bridges{"telegram": &telegram, "mastodon": &mastodon, "bluesky": &bluesky, "signalGroup": &signalGroup, "matrix": &matrix} } func (b *Bridges) Update(ticker storage.Ticker) error { diff --git a/internal/bridge/bridge_test.go b/internal/bridge/bridge_test.go index 792e3887..50953748 100644 --- a/internal/bridge/bridge_test.go +++ b/internal/bridge/bridge_test.go @@ -171,7 +171,7 @@ func (s *BridgeTestSuite) TestDelete() { func (s *BridgeTestSuite) TestRegisterBridges() { bridges := RegisterBridges(config.Config{}, nil) - s.Equal(4, len(bridges)) + s.Equal(5, len(bridges)) } func TestBrigde(t *testing.T) { diff --git a/internal/bridge/matrix_room.go b/internal/bridge/matrix_room.go new file mode 100644 index 00000000..41d19f2c --- /dev/null +++ b/internal/bridge/matrix_room.go @@ -0,0 +1,34 @@ +package bridge + +import ( + "github.com/systemli/ticker/internal/config" + "github.com/systemli/ticker/internal/storage" +) + +type MatrixBridge struct { + config config.Config + storage storage.Storage +} + +// Update is called when a ticker is updated +func (mb *MatrixBridge) Update(ticker storage.Ticker) error { + if !mb.config.Matrix.Enabled() || !ticker.Matrix.Connected() { + return nil + } + + // Call + + return nil +} + +// Send sends a message to a Matrix room +func (mb *MatrixBridge) Send(ticker storage.Ticker, message *storage.Message) error { + // Not implemented yet + return nil +} + +// Delete deletes a message from a Matrix room +func (mb *MatrixBridge) Delete(ticker storage.Ticker, message *storage.Message) error { + // Not implemented yet + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 2e21b51f..78750711 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ type Config struct { Database Database `yaml:"database"` Telegram Telegram `yaml:"telegram"` SignalGroup SignalGroup `yaml:"signal_group"` + Matrix Matrix `yaml:"matrix"` MetricsListen string `yaml:"metrics_listen"` Upload Upload `yaml:"upload"` FileBackend afero.Fs @@ -42,6 +43,12 @@ type SignalGroup struct { Account string `yaml:"account"` } +type Matrix struct { + ApiUrl string `yaml:"api_url"` + Token string `yaml:"token"` + Homeserver string `yaml:"homeserver"` +} + type Upload struct { Path string `yaml:"path"` URL string `yaml:"url"` @@ -75,6 +82,11 @@ func (t *SignalGroup) Enabled() bool { return t.ApiUrl != "" && t.Account != "" } +// Enabled returns true if required API URL and token are set. +func (m *Matrix) Enabled() bool { + return m.ApiUrl != "" && m.Token != "" && m.Homeserver != "" +} + // LoadConfig loads config from file. func LoadConfig(path string) Config { c := defaultConfig() @@ -129,6 +141,15 @@ func LoadConfig(path string) Config { if os.Getenv("TICKER_SIGNAL_GROUP_AVATAR") != "" { c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_AVATAR") } + if os.Getenv("TICKER_MATRIX_API_URL") != "" { + c.Matrix.ApiUrl = os.Getenv("TICKER_MATRIX_API_URL") + } + if os.Getenv("TICKER_MATRIX_TOKEN") != "" { + c.Matrix.Token = os.Getenv("TICKER_MATRIX_TOKEN") + } + if os.Getenv("TICKER_MATRIX_HOMESERVER") != "" { + c.Matrix.Token = os.Getenv("TICKER_MATRIX_HOMESERVER") + } return c } diff --git a/internal/matrix/matrix.go b/internal/matrix/matrix.go new file mode 100644 index 00000000..d50829f6 --- /dev/null +++ b/internal/matrix/matrix.go @@ -0,0 +1,174 @@ +package matrix + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + + "github.com/systemli/ticker/internal/config" + "github.com/systemli/ticker/internal/logger" + "github.com/systemli/ticker/internal/storage" +) + +var log = logger.GetWithPackage("matrix") + +// CreateRoomRequest represents the request payload for creating a Matrix room +type CreateRoomRequest struct { + Name string `json:"name"` + RoomAliasName string `json:"room_alias_name"` + Preset string `json:"preset"` + Visibility string `json:"visibility"` + PowerLevelContentOverride map[string]interface{} `json:"power_level_content_override,omitempty"` +} + +// CreateRoomResponse represents the response from creating a Matrix room +type CreateRoomResponse struct { + RoomID string `json:"room_id"` +} + +// MatrixErrorResponse represents an error response from the Matrix API +type MatrixErrorResponse struct { + ErrCode string `json:"errcode"` + Error string `json:"error"` +} + +// CreateRoom creates a new public room in Matrix using the Synapse API +func CreateRoom(cfg config.Config, t *storage.Ticker) (string, string, error) { + if !cfg.Matrix.Enabled() { + return "", "", fmt.Errorf("matrix bridge is not configured") + } + + // Sanitize room name: convert to ASCII-only, remove spaces and special characters + baseRoomName := sanitizeRoomName(t.Title) + + // Try to create the room, incrementing a suffix if the alias is already taken + const maxRetries = 10 + for i := 0; i < maxRetries; i++ { + roomAliasName := baseRoomName + if i > 0 { + roomAliasName = fmt.Sprintf("%s-%d", baseRoomName, i) + } + + roomID, err := attemptCreateRoom(cfg, t.Title, roomAliasName) + if err == nil { + return roomID, roomAliasName, nil + } + + // Check if the error is due to room alias already being taken + if !isRoomInUseError(err) { + return "", "", err + } + + // If it's a room in use error, try again with incremented suffix + log.WithField("room_alias", roomAliasName).Debug("Room alias already taken, trying with incremented suffix") + } + + return "", "", fmt.Errorf("failed to create room after %d attempts: all aliases taken", maxRetries) +} + +// attemptCreateRoom attempts to create a Matrix room with the given alias name +func attemptCreateRoom(cfg config.Config, title, roomAliasName string) (string, error) { + url := fmt.Sprintf("%s/_matrix/client/v3/createRoom", cfg.Matrix.ApiUrl) + + requestBody := CreateRoomRequest{ + Name: title, + RoomAliasName: roomAliasName, + Preset: "public_chat", + Visibility: "public", + // Set default power levels: allow anyone to invite and restrict sending messages to moderators and above + PowerLevelContentOverride: map[string]interface{}{ + "invite": 0, + "events_default": 50, + }, + } + + jsonData, err := json.Marshal(requestBody) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Matrix.Token)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + // Try to parse the error response + var matrixErr MatrixErrorResponse + if err := json.Unmarshal(body, &matrixErr); err == nil { + return "", &matrixError{ + StatusCode: resp.StatusCode, + ErrCode: matrixErr.ErrCode, + Message: matrixErr.Error, + } + } + return "", fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var response CreateRoomResponse + if err := json.Unmarshal(body, &response); err != nil { + return "", fmt.Errorf("failed to unmarshal response: %w", err) + } + + log.WithField("room_id", response.RoomID).WithField("room_name", title).WithField("room_alias", roomAliasName).Info("Matrix room created successfully") + + return response.RoomID, nil +} + +// matrixError represents a structured Matrix API error +type matrixError struct { + StatusCode int + ErrCode string + Message string +} + +func (e *matrixError) Error() string { + return fmt.Sprintf("Matrix API error (status %d): %s - %s", e.StatusCode, e.ErrCode, e.Message) +} + +// isRoomInUseError checks if the error is due to the room alias already being taken +func isRoomInUseError(err error) bool { + if matrixErr, ok := err.(*matrixError); ok { + return matrixErr.ErrCode == "M_ROOM_IN_USE" + } + return false +} + +// sanitizeRoomName converts a room name to an ASCII-only variant without spaces +func sanitizeRoomName(name string) string { + // Convert to lowercase + name = strings.ToLower(name) + + // Remove non-ASCII characters + asciiOnly := regexp.MustCompile(`[^a-z0-9-_]`) + name = asciiOnly.ReplaceAllString(name, "") + + // Trim leading and trailing special characters + name = strings.Trim(name, "-_") + + // If the name is empty after sanitization, use a default + if name == "" { + name = "ticker-room" + } + + return name +} diff --git a/internal/matrix/matrix_test.go b/internal/matrix/matrix_test.go new file mode 100644 index 00000000..bbecead6 --- /dev/null +++ b/internal/matrix/matrix_test.go @@ -0,0 +1,87 @@ +package matrix + +import ( +"encoding/json" +"net/http" +"net/http/httptest" +"testing" + +"github.com/stretchr/testify/assert" +"github.com/systemli/ticker/internal/config" +) + +func TestCreateRoom(t *testing.T) { +// Mock server to simulate Synapse API +server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// Verify request method and path +assert.Equal(t, http.MethodPost, r.Method) +assert.Equal(t, "/_matrix/client/v3/createRoom", r.URL.Path) + +// Verify authorization header +assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) +assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + +// Verify request body +var req CreateRoomRequest +err := json.NewDecoder(r.Body).Decode(&req) +assert.NoError(t, err) +assert.Equal(t, "test-room", req.Name) +assert.Equal(t, "public_chat", req.Preset) +assert.Equal(t, "public", req.Visibility) +assert.Equal(t, "test-room", req.RoomAliasName) + +// Send response +response := CreateRoomResponse{ +RoomID: "!test-room-id:example.com", +} +w.Header().Set("Content-Type", "application/json") +w.WriteHeader(http.StatusOK) +json.NewEncoder(w).Encode(response) +})) +defer server.Close() + +// Create config +cfg := config.Config{ +Matrix: config.Matrix{ +ApiUrl: server.URL, +Token: "test-token", +}, +} + +// Test CreateRoom +roomID, err := CreateRoom(cfg, "test-room") +assert.NoError(t, err) + assert.Equal(t, "!test-room-id:example.com", roomID) +} + +func TestCreateRoom_NotConfigured(t *testing.T) { +// Create config without Matrix configuration +cfg := config.Config{} + +// Test CreateRoom with unconfigured bridge +_, err := CreateRoom(cfg, "test-room") +assert.Error(t, err) +assert.Contains(t, err.Error(), "matrix bridge is not configured") +} + +func TestCreateRoom_APIError(t *testing.T) { +// Mock server that returns an error +server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +w.WriteHeader(http.StatusUnauthorized) +w.Write([]byte(`{"errcode":"M_FORBIDDEN","error":"Invalid access token"}`)) +})) +defer server.Close() + +// Create config +cfg := config.Config{ +Matrix: config.Matrix{ +ApiUrl: server.URL, +Token: "invalid-token", +}, +} + +// Test CreateRoom with invalid token +_, err := CreateRoom(cfg, "test-room") +assert.Error(t, err) +assert.Contains(t, err.Error(), "API request failed with status 401") +} diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index cd0a47a5..a0a0de72 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -12,6 +12,7 @@ func MigrateDB(db *gorm.DB) error { &TickerTelegram{}, &TickerBluesky{}, &TickerSignalGroup{}, + &TickerMatrix{}, &TickerWebsite{}, &User{}, &Setting{}, diff --git a/internal/storage/mock_Storage.go b/internal/storage/mock_Storage.go index 9e962a58..3ec12e57 100644 --- a/internal/storage/mock_Storage.go +++ b/internal/storage/mock_Storage.go @@ -400,6 +400,57 @@ func (_c *MockStorage_DeleteSignalGroup_Call) RunAndReturn(run func(ticker *Tick return _c } +// DeleteMatrix provides a mock function for the type MockStorage +func (_mock *MockStorage) DeleteMatrix(ticker *Ticker) error { + ret := _mock.Called(ticker) + + if len(ret) == 0 { + panic("no return value specified for DeleteMatrix") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*Ticker) error); ok { + r0 = returnFunc(ticker) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockStorage_DeleteMatrix_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteMatrix' +type MockStorage_DeleteMatrix_Call struct { + *mock.Call +} + +// DeleteMatrix is a helper method to define mock.On call +// - ticker *Ticker +func (_e *MockStorage_Expecter) DeleteMatrix(ticker interface{}) *MockStorage_DeleteMatrix_Call { + return &MockStorage_DeleteMatrix_Call{Call: _e.mock.On("DeleteMatrix", ticker)} +} + +func (_c *MockStorage_DeleteMatrix_Call) Run(run func(ticker *Ticker)) *MockStorage_DeleteMatrix_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *Ticker + if args[0] != nil { + arg0 = args[0].(*Ticker) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockStorage_DeleteMatrix_Call) Return(err error) *MockStorage_DeleteMatrix_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockStorage_DeleteMatrix_Call) RunAndReturn(run func(ticker *Ticker) error) *MockStorage_DeleteMatrix_Call { + _c.Call.Return(run) + return _c +} + // DeleteTelegram provides a mock function for the type MockStorage func (_mock *MockStorage) DeleteTelegram(ticker *Ticker) error { ret := _mock.Called(ticker) diff --git a/internal/storage/sql_storage.go b/internal/storage/sql_storage.go index ea5ceea3..f447f643 100644 --- a/internal/storage/sql_storage.go +++ b/internal/storage/sql_storage.go @@ -318,6 +318,10 @@ func (s *SqlStorage) DeleteIntegrations(ticker *Ticker) error { return err } + if err := s.DeleteMatrix(ticker); err != nil { + return err + } + return nil } @@ -345,6 +349,12 @@ func (s *SqlStorage) DeleteSignalGroup(ticker *Ticker) error { return s.DB.Delete(TickerSignalGroup{}, EqualTickerID, ticker.ID).Error } +func (s *SqlStorage) DeleteMatrix(ticker *Ticker) error { + ticker.Matrix = TickerMatrix{} + + return s.DB.Delete(TickerMatrix{}, EqualTickerID, ticker.ID).Error +} + func (s *SqlStorage) FindUploadByUUID(uuid string) (Upload, error) { var upload Upload diff --git a/internal/storage/sql_storage_test.go b/internal/storage/sql_storage_test.go index 99131e84..5ebadf43 100644 --- a/internal/storage/sql_storage_test.go +++ b/internal/storage/sql_storage_test.go @@ -31,6 +31,7 @@ func (s *SqlStorageTestSuite) SetupTest() { &TickerMastodon{}, &TickerBluesky{}, &TickerSignalGroup{}, + &TickerMatrix{}, &TickerWebsite{}, &User{}, &Message{}, diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ab48ebeb..37a6e2d4 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -34,6 +34,7 @@ type Storage interface { DeleteTelegram(ticker *Ticker) error DeleteBluesky(ticker *Ticker) error DeleteSignalGroup(ticker *Ticker) error + DeleteMatrix(ticker *Ticker) error SaveUpload(upload *Upload) error FindUploadByUUID(uuid string) (Upload, error) FindUploadsByIDs(ids []int) ([]Upload, error) diff --git a/internal/storage/ticker.go b/internal/storage/ticker.go index 385546b8..adf18af5 100644 --- a/internal/storage/ticker.go +++ b/internal/storage/ticker.go @@ -20,6 +20,7 @@ type Ticker struct { Mastodon TickerMastodon Bluesky TickerBluesky SignalGroup TickerSignalGroup + Matrix TickerMatrix Websites []TickerWebsite `gorm:"foreignKey:TickerID;"` Users []User `gorm:"many2many:ticker_users;"` } @@ -139,6 +140,20 @@ func (s *TickerSignalGroup) Connected() bool { return s.GroupID != "" } +type TickerMatrix struct { + ID int `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + TickerID int `gorm:"index"` + Active bool + RoomID string + RoomName string +} + +func (m *TickerMatrix) Connected() bool { + return m.RoomID != "" +} + type TickerLocation struct { Lat float64 Lon float64 From 94a97998ea1df35e363ba0a4cf60e96c69172907 Mon Sep 17 00:00:00 2001 From: doobry Date: Sat, 3 Jan 2026 19:10:18 +0100 Subject: [PATCH 2/9] enable end-to-end encryption when creating matrix room --- internal/matrix/matrix.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/internal/matrix/matrix.go b/internal/matrix/matrix.go index d50829f6..993dc7ae 100644 --- a/internal/matrix/matrix.go +++ b/internal/matrix/matrix.go @@ -18,11 +18,12 @@ var log = logger.GetWithPackage("matrix") // CreateRoomRequest represents the request payload for creating a Matrix room type CreateRoomRequest struct { - Name string `json:"name"` - RoomAliasName string `json:"room_alias_name"` - Preset string `json:"preset"` - Visibility string `json:"visibility"` - PowerLevelContentOverride map[string]interface{} `json:"power_level_content_override,omitempty"` + Name string `json:"name"` + RoomAliasName string `json:"room_alias_name"` + Preset string `json:"preset"` + Visibility string `json:"visibility"` + PowerLevelContentOverride map[string]interface{} `json:"power_level_content_override,omitempty"` + InitialState []map[string]interface{} `json:"initial_state,omitempty"` } // CreateRoomResponse represents the response from creating a Matrix room @@ -84,6 +85,15 @@ func attemptCreateRoom(cfg config.Config, title, roomAliasName string) (string, "invite": 0, "events_default": 50, }, + // Enable end-to-end encryption for the room + InitialState: []map[string]interface{}{ + { + "type": "m.room.encryption", + "content": map[string]interface{}{ + "algorithm": "m.megolm.v1.aes-sha2", + }, + }, + }, } jsonData, err := json.Marshal(requestBody) From b21f242062abb0c2d75eb8a33b502047f5857f72 Mon Sep 17 00:00:00 2001 From: doobry Date: Sat, 3 Jan 2026 19:13:47 +0100 Subject: [PATCH 3/9] Only try to create matrix room when there's none configured yet --- internal/api/tickers.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/internal/api/tickers.go b/internal/api/tickers.go index a2360d80..fb343327 100644 --- a/internal/api/tickers.go +++ b/internal/api/tickers.go @@ -501,15 +501,19 @@ func (h *handler) PutTickerMatrix(c *gin.Context) { return } - roomID, roomAliasName, err := matrix.CreateRoom(h.config, &ticker) - if err != nil { - log.WithError(err).Error("failed to create matrix room") - c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.MatrixError)) - return + // Only create the room if it doesn't exist yet + if ticker.Matrix.RoomID == "" { + roomID, roomAliasName, err := matrix.CreateRoom(h.config, &ticker) + if err != nil { + log.WithError(err).Error("failed to create matrix room") + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.MatrixError)) + return + } + + ticker.Matrix.RoomID = roomID + ticker.Matrix.RoomName = roomAliasName } - ticker.Matrix.RoomID = roomID - ticker.Matrix.RoomName = roomAliasName ticker.Matrix.Active = body.Active err = h.storage.SaveTicker(&ticker) From f0cd1dca3c862097692b259ff815d34581d5ad3a Mon Sep 17 00:00:00 2001 From: doobry Date: Sat, 3 Jan 2026 19:51:05 +0100 Subject: [PATCH 4/9] Kick all members from room and leave room when deleting matrix room --- internal/api/tickers.go | 12 +++ internal/matrix/matrix.go | 204 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) diff --git a/internal/api/tickers.go b/internal/api/tickers.go index fb343327..59984e51 100644 --- a/internal/api/tickers.go +++ b/internal/api/tickers.go @@ -532,6 +532,18 @@ func (h *handler) DeleteTickerMatrix(c *gin.Context) { return } + err = matrix.RemoveAllMembers(h.config, ticker.Matrix.RoomID) + if err != nil { + log.WithError(err).Error("failed to remove members") + return + } + + err = matrix.LeaveRoom(h.config, ticker.Matrix.RoomID) + if err != nil { + log.WithError(err).Error("failed to leave room") + return + } + err = h.storage.DeleteMatrix(&ticker) if err != nil { c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError)) diff --git a/internal/matrix/matrix.go b/internal/matrix/matrix.go index 993dc7ae..2689d951 100644 --- a/internal/matrix/matrix.go +++ b/internal/matrix/matrix.go @@ -37,6 +37,24 @@ type MatrixErrorResponse struct { Error string `json:"error"` } +// MembersResponse represents the response from getting room members +type MembersResponse struct { + Chunk []MemberEvent `json:"chunk"` +} + +// MemberEvent represents a single member event +type MemberEvent struct { + Type string `json:"type"` + StateKey string `json:"state_key"` + Content map[string]string `json:"content"` +} + +// KickRequest represents the request to kick a user from a room +type KickRequest struct { + UserID string `json:"user_id"` + Reason string `json:"reason"` +} + // CreateRoom creates a new public room in Matrix using the Synapse API func CreateRoom(cfg config.Config, t *storage.Ticker) (string, string, error) { if !cfg.Matrix.Enabled() { @@ -182,3 +200,189 @@ func sanitizeRoomName(name string) string { return name } + +// RemoveAllMembers removes all members from a Matrix room except the bot itself +func RemoveAllMembers(cfg config.Config, roomID string) error { + if !cfg.Matrix.Enabled() { + return fmt.Errorf("matrix bridge is not configured") + } + + // First, get the current user ID to avoid kicking ourselves + myUserID, err := getCurrentUser(cfg) + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + log.Debug("Current Matrix user ID: ", myUserID) + + // Get all room members + members, err := getRoomMembers(cfg, roomID) + if err != nil { + return fmt.Errorf("failed to get room members: %w", err) + } + log.Debug("Current Matrix room members: ", members) + + // Kick each member except ourselves + for _, member := range members { + if member.StateKey != myUserID { + err := kickUser(cfg, roomID, member.StateKey) + if err != nil { + log.WithError(err).WithField("user_id", member.StateKey).Error("failed to kick user") + // Continue kicking other users even if one fails + } else { + log.WithField("user_id", member.StateKey).Info("kicked user from Matrix room") + } + } + } + + return nil +} + +// getCurrentUser gets the user ID of the bot +func getCurrentUser(cfg config.Config) (string, error) { + url := fmt.Sprintf("%s/_matrix/client/v3/account/whoami", cfg.Matrix.ApiUrl) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Matrix.Token)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var response struct { + UserID string `json:"user_id"` + } + if err := json.Unmarshal(body, &response); err != nil { + return "", fmt.Errorf("failed to unmarshal response: %w", err) + } + + return response.UserID, nil +} + +// getRoomMembers gets all members of a Matrix room +func getRoomMembers(cfg config.Config, roomID string) ([]MemberEvent, error) { + url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/members", cfg.Matrix.ApiUrl, roomID) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Matrix.Token)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var response MembersResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return response.Chunk, nil +} + +// kickUser kicks a user from a Matrix room +func kickUser(cfg config.Config, roomID, userID string) error { + url := fmt.Sprintf("%s/_matrix/client/r0/rooms/%s/kick", cfg.Matrix.ApiUrl, roomID) + + kickReq := KickRequest{ + UserID: userID, + Reason: "Room is being deleted", + } + + jsonData, err := json.Marshal(kickReq) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Matrix.Token)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// LeaveRoom leaves a Matrix room +func LeaveRoom(cfg config.Config, roomID string) error { + if !cfg.Matrix.Enabled() { + return fmt.Errorf("matrix bridge is not configured") + } + + url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/leave", cfg.Matrix.ApiUrl, roomID) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer([]byte("{}"))) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Matrix.Token)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + log.WithField("room_id", roomID).Info("left Matrix room") + + return nil +} From 0036d62dc280d932584532d7b9aa785956a4cf55 Mon Sep 17 00:00:00 2001 From: doobry Date: Sun, 4 Jan 2026 14:03:46 +0100 Subject: [PATCH 5/9] persist cannonical room alias name in database --- config.yml.dist | 1 - internal/api/response/ticker.go | 16 +++++------ internal/api/tickers.go | 4 +-- internal/config/config.go | 6 +---- internal/matrix/matrix.go | 48 ++++++++++++++++++++++++++++++++- 5 files changed, 58 insertions(+), 17 deletions(-) diff --git a/config.yml.dist b/config.yml.dist index c32245ae..336a1f0d 100644 --- a/config.yml.dist +++ b/config.yml.dist @@ -22,7 +22,6 @@ signal_group: matrix: api_url: "" token: "" - homeserver: "" # listen port for prometheus metrics exporter metrics_listen: ":8181" upload: diff --git a/internal/api/response/ticker.go b/internal/api/response/ticker.go index 980ceb02..906c4454 100644 --- a/internal/api/response/ticker.go +++ b/internal/api/response/ticker.go @@ -73,10 +73,10 @@ type SignalGroup struct { } type Matrix struct { - Active bool `json:"active"` - Connected bool `json:"connected"` - RoomID string `json:"roomID"` - RoomName string `json:"roomName"` + Active bool `json:"active"` + Connected bool `json:"connected"` + RoomID string `json:"roomID"` + RoomName string `json:"roomName"` } type Location struct { @@ -139,10 +139,10 @@ func TickerResponse(t storage.Ticker, config config.Config) Ticker { GroupInviteLink: t.SignalGroup.GroupInviteLink, }, Matrix: Matrix{ - Active: t.Matrix.Active, - Connected: t.Matrix.Connected(), - RoomID: t.Matrix.RoomID, - RoomName: t.Matrix.RoomName, + Active: t.Matrix.Active, + Connected: t.Matrix.Connected(), + RoomID: t.Matrix.RoomID, + RoomName: t.Matrix.RoomName, }, Location: Location{ Lat: t.Location.Lat, diff --git a/internal/api/tickers.go b/internal/api/tickers.go index 59984e51..18368a6e 100644 --- a/internal/api/tickers.go +++ b/internal/api/tickers.go @@ -503,7 +503,7 @@ func (h *handler) PutTickerMatrix(c *gin.Context) { // Only create the room if it doesn't exist yet if ticker.Matrix.RoomID == "" { - roomID, roomAliasName, err := matrix.CreateRoom(h.config, &ticker) + roomID, roomName, err := matrix.CreateRoom(h.config, &ticker) if err != nil { log.WithError(err).Error("failed to create matrix room") c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.MatrixError)) @@ -511,7 +511,7 @@ func (h *handler) PutTickerMatrix(c *gin.Context) { } ticker.Matrix.RoomID = roomID - ticker.Matrix.RoomName = roomAliasName + ticker.Matrix.RoomName = roomName } ticker.Matrix.Active = body.Active diff --git a/internal/config/config.go b/internal/config/config.go index 78750711..f7a0230e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,7 +46,6 @@ type SignalGroup struct { type Matrix struct { ApiUrl string `yaml:"api_url"` Token string `yaml:"token"` - Homeserver string `yaml:"homeserver"` } type Upload struct { @@ -84,7 +83,7 @@ func (t *SignalGroup) Enabled() bool { // Enabled returns true if required API URL and token are set. func (m *Matrix) Enabled() bool { - return m.ApiUrl != "" && m.Token != "" && m.Homeserver != "" + return m.ApiUrl != "" && m.Token != "" } // LoadConfig loads config from file. @@ -147,9 +146,6 @@ func LoadConfig(path string) Config { if os.Getenv("TICKER_MATRIX_TOKEN") != "" { c.Matrix.Token = os.Getenv("TICKER_MATRIX_TOKEN") } - if os.Getenv("TICKER_MATRIX_HOMESERVER") != "" { - c.Matrix.Token = os.Getenv("TICKER_MATRIX_HOMESERVER") - } return c } diff --git a/internal/matrix/matrix.go b/internal/matrix/matrix.go index 2689d951..da096428 100644 --- a/internal/matrix/matrix.go +++ b/internal/matrix/matrix.go @@ -55,6 +55,11 @@ type KickRequest struct { Reason string `json:"reason"` } +// CanonicalAliasResponse represents the response from getting the canonical alias +type CanonicalAliasResponse struct { + Alias string `json:"alias"` +} + // CreateRoom creates a new public room in Matrix using the Synapse API func CreateRoom(cfg config.Config, t *storage.Ticker) (string, string, error) { if !cfg.Matrix.Enabled() { @@ -74,7 +79,13 @@ func CreateRoom(cfg config.Config, t *storage.Ticker) (string, string, error) { roomID, err := attemptCreateRoom(cfg, t.Title, roomAliasName) if err == nil { - return roomID, roomAliasName, nil + // Get the canonical alias from the Matrix API + roomName, err := getCanonicalAlias(cfg, roomID) + if err != nil { + log.WithError(err).WithField("room_id", roomID).Warn("failed to get canonical alias, using constructed alias") + return roomID, roomAliasName, nil + } + return roomID, roomName, nil } // Check if the error is due to room alias already being taken @@ -201,6 +212,41 @@ func sanitizeRoomName(name string) string { return name } +// getCanonicalAlias gets the canonical alias for a Matrix room +func getCanonicalAlias(cfg config.Config, roomID string) (string, error) { + url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/state/m.room.canonical_alias", cfg.Matrix.ApiUrl, roomID) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Matrix.Token)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var response CanonicalAliasResponse + if err := json.Unmarshal(body, &response); err != nil { + return "", fmt.Errorf("failed to unmarshal response: %w", err) + } + + return response.Alias, nil +} + // RemoveAllMembers removes all members from a Matrix room except the bot itself func RemoveAllMembers(cfg config.Config, roomID string) error { if !cfg.Matrix.Enabled() { From 95d0d030f22d4d848188fa0cee7f6e2f055efeb5 Mon Sep 17 00:00:00 2001 From: doobry Date: Sun, 4 Jan 2026 16:10:29 +0100 Subject: [PATCH 6/9] refactor matrix API calls to use maunium.net/go/mautrix --- go.mod | 11 +- go.sum | 33 ++- internal/bridge/bridge.go | 4 +- internal/bridge/matrix_room.go | 16 +- internal/matrix/matrix.go | 371 ++++++++------------------------- internal/matrix/matrix_test.go | 148 +++++++------ 6 files changed, 220 insertions(+), 363 deletions(-) diff --git a/go.mod b/go.mod index f1b784d1..fe531473 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 + maunium.net/go/mautrix v0.26.1 ) require ( @@ -79,6 +80,7 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/minio/sha256-simd v1.0.1 // indirect @@ -99,14 +101,20 @@ require ( github.com/prometheus/procfs v0.19.2 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.1 // indirect + github.com/rs/zerolog v1.34.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.3 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/whyrusleeping/cbor-gen v0.3.1 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.mau.fi/util v0.9.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect @@ -118,12 +126,13 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/arch v0.23.0 // indirect + golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/protobuf v1.36.11 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index e2f0d6d6..7b505a9b 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -64,6 +65,7 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= @@ -157,8 +159,11 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-mastodon v0.0.10 h1:wz1d/aCkJOIkz46iv4eAqXHVreUMxydY1xBWrPBdDeE= @@ -193,6 +198,7 @@ github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYr github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= @@ -212,6 +218,9 @@ github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlo github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sethvargo/go-password v0.3.1 h1:WqrLTjo7X6AcVYfC6R7GtSyuUQR9hGyAj/f1PYQZCJU= @@ -247,12 +256,16 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= -github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e h1:tD38/4xg4nuQCASJ/JxcvCHNb46w0cdAaJfkzQOO1bA= github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e/go.mod h1:krvJ5AY/MjdPkTeRgMYbIDhbbbVvnPQPzsIsDJO8xrY= github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f h1:oqdnd6OGlOUu1InG37hWcCB3a+Jy3fwjylyVboaNMwY= @@ -272,6 +285,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zU github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.mau.fi/util v0.9.4 h1:gWdUff+K2rCynRPysXalqqQyr2ahkSWaestH6YhSpso= +go.mau.fi/util v0.9.4/go.mod h1:647nVfwUvuhlZFOnro3aRNPmRd2y3iDha9USb8aKSmM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= @@ -313,6 +328,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= @@ -336,7 +353,9 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -360,8 +379,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -382,3 +401,5 @@ gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= +maunium.net/go/mautrix v0.26.1 h1:FWCC1xY5vwJ5ou3duEBjB6w9IIlwfc9el3q3Mju3Dlg= +maunium.net/go/mautrix v0.26.1/go.mod h1:UySSpb8OqXG1sMJ6dDqyzmfcqr2ayZK+KzwqOTAkAOM= diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go index 107aa3a3..bd77ebae 100644 --- a/internal/bridge/bridge.go +++ b/internal/bridge/bridge.go @@ -21,9 +21,9 @@ func RegisterBridges(config config.Config, storage storage.Storage) Bridges { mastodon := MastodonBridge{config, storage} bluesky := BlueskyBridge{config, storage} signalGroup := SignalGroupBridge{config, storage} - matrix := MatrixBridge{config, storage} + matrixRoom := MatrixRoomBridge{config, storage} - return Bridges{"telegram": &telegram, "mastodon": &mastodon, "bluesky": &bluesky, "signalGroup": &signalGroup, "matrix": &matrix} + return Bridges{"telegram": &telegram, "mastodon": &mastodon, "bluesky": &bluesky, "signalGroup": &signalGroup, "matrixRoom": &matrixRoom} } func (b *Bridges) Update(ticker storage.Ticker) error { diff --git a/internal/bridge/matrix_room.go b/internal/bridge/matrix_room.go index 41d19f2c..583b125f 100644 --- a/internal/bridge/matrix_room.go +++ b/internal/bridge/matrix_room.go @@ -2,33 +2,37 @@ package bridge import ( "github.com/systemli/ticker/internal/config" + "github.com/systemli/ticker/internal/matrix" "github.com/systemli/ticker/internal/storage" ) -type MatrixBridge struct { +type MatrixRoomBridge struct { config config.Config storage storage.Storage } -// Update is called when a ticker is updated -func (mb *MatrixBridge) Update(ticker storage.Ticker) error { +func (mb *MatrixRoomBridge) Update(ticker storage.Ticker) error { if !mb.config.Matrix.Enabled() || !ticker.Matrix.Connected() { return nil } - // Call + // Rename room if title has changed + err := matrix.UpdateRoomName(mb.config, ticker.Matrix.RoomID, ticker.Title) + if err != nil { + return err + } return nil } // Send sends a message to a Matrix room -func (mb *MatrixBridge) Send(ticker storage.Ticker, message *storage.Message) error { +func (mb *MatrixRoomBridge) Send(ticker storage.Ticker, message *storage.Message) error { // Not implemented yet return nil } // Delete deletes a message from a Matrix room -func (mb *MatrixBridge) Delete(ticker storage.Ticker, message *storage.Message) error { +func (mb *MatrixRoomBridge) Delete(ticker storage.Ticker, message *storage.Message) error { // Not implemented yet return nil } diff --git a/internal/matrix/matrix.go b/internal/matrix/matrix.go index da096428..0845d6b0 100644 --- a/internal/matrix/matrix.go +++ b/internal/matrix/matrix.go @@ -1,14 +1,15 @@ package matrix import ( - "bytes" - "encoding/json" + "context" "fmt" - "io" - "net/http" "regexp" "strings" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "github.com/systemli/ticker/internal/config" "github.com/systemli/ticker/internal/logger" "github.com/systemli/ticker/internal/storage" @@ -16,54 +17,24 @@ import ( var log = logger.GetWithPackage("matrix") -// CreateRoomRequest represents the request payload for creating a Matrix room -type CreateRoomRequest struct { - Name string `json:"name"` - RoomAliasName string `json:"room_alias_name"` - Preset string `json:"preset"` - Visibility string `json:"visibility"` - PowerLevelContentOverride map[string]interface{} `json:"power_level_content_override,omitempty"` - InitialState []map[string]interface{} `json:"initial_state,omitempty"` -} - -// CreateRoomResponse represents the response from creating a Matrix room -type CreateRoomResponse struct { - RoomID string `json:"room_id"` -} - -// MatrixErrorResponse represents an error response from the Matrix API -type MatrixErrorResponse struct { - ErrCode string `json:"errcode"` - Error string `json:"error"` -} - -// MembersResponse represents the response from getting room members -type MembersResponse struct { - Chunk []MemberEvent `json:"chunk"` -} - -// MemberEvent represents a single member event -type MemberEvent struct { - Type string `json:"type"` - StateKey string `json:"state_key"` - Content map[string]string `json:"content"` -} - -// KickRequest represents the request to kick a user from a room -type KickRequest struct { - UserID string `json:"user_id"` - Reason string `json:"reason"` -} +// getClient creates a new mautrix client with the configured credentials +func getClient(cfg config.Config) (*mautrix.Client, error) { + if !cfg.Matrix.Enabled() { + return nil, fmt.Errorf("matrix bridge is not configured") + } -// CanonicalAliasResponse represents the response from getting the canonical alias -type CanonicalAliasResponse struct { - Alias string `json:"alias"` + client, err := mautrix.NewClient(cfg.Matrix.ApiUrl, "", cfg.Matrix.Token) + if err != nil { + return nil, fmt.Errorf("failed to create matrix client: %w", err) + } + return client, nil } // CreateRoom creates a new public room in Matrix using the Synapse API func CreateRoom(cfg config.Config, t *storage.Ticker) (string, string, error) { - if !cfg.Matrix.Enabled() { - return "", "", fmt.Errorf("matrix bridge is not configured") + client, err := getClient(cfg) + if err != nil { + return "", "", err } // Sanitize room name: convert to ASCII-only, remove spaces and special characters @@ -77,15 +48,15 @@ func CreateRoom(cfg config.Config, t *storage.Ticker) (string, string, error) { roomAliasName = fmt.Sprintf("%s-%d", baseRoomName, i) } - roomID, err := attemptCreateRoom(cfg, t.Title, roomAliasName) + roomID, err := attemptCreateRoom(client, t.Title, roomAliasName) if err == nil { // Get the canonical alias from the Matrix API - roomName, err := getCanonicalAlias(cfg, roomID) + roomName, err := getCanonicalAlias(client, roomID) if err != nil { log.WithError(err).WithField("room_id", roomID).Warn("failed to get canonical alias, using constructed alias") - return roomID, roomAliasName, nil + return string(roomID), roomAliasName, nil } - return roomID, roomName, nil + return string(roomID), roomName, nil } // Check if the error is due to room alias already being taken @@ -101,93 +72,43 @@ func CreateRoom(cfg config.Config, t *storage.Ticker) (string, string, error) { } // attemptCreateRoom attempts to create a Matrix room with the given alias name -func attemptCreateRoom(cfg config.Config, title, roomAliasName string) (string, error) { - url := fmt.Sprintf("%s/_matrix/client/v3/createRoom", cfg.Matrix.ApiUrl) - - requestBody := CreateRoomRequest{ +func attemptCreateRoom(client *mautrix.Client, title, roomAliasName string) (id.RoomID, error) { + inviteLevel := 0 + resp, err := client.CreateRoom(context.Background(), &mautrix.ReqCreateRoom{ Name: title, RoomAliasName: roomAliasName, Preset: "public_chat", Visibility: "public", - // Set default power levels: allow anyone to invite and restrict sending messages to moderators and above - PowerLevelContentOverride: map[string]interface{}{ - "invite": 0, - "events_default": 50, + PowerLevelOverride: &event.PowerLevelsEventContent{ + InvitePtr: &inviteLevel, + EventsDefault: 50, }, - // Enable end-to-end encryption for the room - InitialState: []map[string]interface{}{ + InitialState: []*event.Event{ { - "type": "m.room.encryption", - "content": map[string]interface{}{ - "algorithm": "m.megolm.v1.aes-sha2", + Type: event.StateEncryption, + Content: event.Content{ + Parsed: &event.EncryptionEventContent{ + Algorithm: id.AlgorithmMegolmV1, + }, }, }, }, - } - - jsonData, err := json.Marshal(requestBody) - if err != nil { - return "", fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Matrix.Token)) - - client := &http.Client{} - resp, err := client.Do(req) + }) if err != nil { - return "", fmt.Errorf("failed to send request: %w", err) + return "", err } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - // Try to parse the error response - var matrixErr MatrixErrorResponse - if err := json.Unmarshal(body, &matrixErr); err == nil { - return "", &matrixError{ - StatusCode: resp.StatusCode, - ErrCode: matrixErr.ErrCode, - Message: matrixErr.Error, - } - } - return "", fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) - } - - var response CreateRoomResponse - if err := json.Unmarshal(body, &response); err != nil { - return "", fmt.Errorf("failed to unmarshal response: %w", err) - } - - log.WithField("room_id", response.RoomID).WithField("room_name", title).WithField("room_alias", roomAliasName).Info("Matrix room created successfully") - - return response.RoomID, nil -} + log.WithField("room_id", resp.RoomID).WithField("room_name", title).WithField("room_alias", roomAliasName).Info("Matrix room created successfully") -// matrixError represents a structured Matrix API error -type matrixError struct { - StatusCode int - ErrCode string - Message string -} - -func (e *matrixError) Error() string { - return fmt.Sprintf("Matrix API error (status %d): %s - %s", e.StatusCode, e.ErrCode, e.Message) + return resp.RoomID, nil } // isRoomInUseError checks if the error is due to the room alias already being taken func isRoomInUseError(err error) bool { - if matrixErr, ok := err.(*matrixError); ok { - return matrixErr.ErrCode == "M_ROOM_IN_USE" + if httpErr, ok := err.(mautrix.HTTPError); ok { + if respErr := httpErr.RespError; respErr != nil { + return respErr.ErrCode == "M_ROOM_IN_USE" + } } return false } @@ -213,69 +134,67 @@ func sanitizeRoomName(name string) string { } // getCanonicalAlias gets the canonical alias for a Matrix room -func getCanonicalAlias(cfg config.Config, roomID string) (string, error) { - url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/state/m.room.canonical_alias", cfg.Matrix.ApiUrl, roomID) - - req, err := http.NewRequest(http.MethodGet, url, nil) +func getCanonicalAlias(client *mautrix.Client, roomID id.RoomID) (string, error) { + var content event.CanonicalAliasEventContent + err := client.StateEvent(context.Background(), roomID, event.StateCanonicalAlias, "", &content) if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) + return "", fmt.Errorf("failed to get canonical alias: %w", err) } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Matrix.Token)) + return content.Alias.String(), nil +} - client := &http.Client{} - resp, err := client.Do(req) +// UpdateRoomName updates the name of a Matrix room +func UpdateRoomName(cfg config.Config, roomID, name string) error { + client, err := getClient(cfg) if err != nil { - return "", fmt.Errorf("failed to send request: %w", err) + return err } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + _, err = client.SendStateEvent(context.Background(), id.RoomID(roomID), event.StateRoomName, "", &event.RoomNameEventContent{ + Name: name, + }) if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) + return fmt.Errorf("failed to update room name: %w", err) } - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) - } + log.WithField("room_id", roomID).WithField("room_name", name).Info("Matrix room name updated successfully") - var response CanonicalAliasResponse - if err := json.Unmarshal(body, &response); err != nil { - return "", fmt.Errorf("failed to unmarshal response: %w", err) - } - - return response.Alias, nil + return nil } // RemoveAllMembers removes all members from a Matrix room except the bot itself func RemoveAllMembers(cfg config.Config, roomID string) error { - if !cfg.Matrix.Enabled() { - return fmt.Errorf("matrix bridge is not configured") + client, err := getClient(cfg) + if err != nil { + return err } // First, get the current user ID to avoid kicking ourselves - myUserID, err := getCurrentUser(cfg) + whoami, err := client.Whoami(context.Background()) if err != nil { return fmt.Errorf("failed to get current user: %w", err) } - log.Debug("Current Matrix user ID: ", myUserID) + myUserID := whoami.UserID // Get all room members - members, err := getRoomMembers(cfg, roomID) + members, err := client.JoinedMembers(context.Background(), id.RoomID(roomID)) if err != nil { return fmt.Errorf("failed to get room members: %w", err) } - log.Debug("Current Matrix room members: ", members) // Kick each member except ourselves - for _, member := range members { - if member.StateKey != myUserID { - err := kickUser(cfg, roomID, member.StateKey) + for userID := range members.Joined { + if userID != myUserID { + _, err := client.KickUser(context.Background(), id.RoomID(roomID), &mautrix.ReqKickUser{ + UserID: userID, + Reason: "Room is being deleted", + }) if err != nil { - log.WithError(err).WithField("user_id", member.StateKey).Error("failed to kick user") + log.WithError(err).WithField("user_id", userID).Error("failed to kick user") // Continue kicking other users even if one fails } else { - log.WithField("user_id", member.StateKey).Info("kicked user from Matrix room") + log.WithField("user_id", userID).Info("kicked user from Matrix room") } } } @@ -283,152 +202,38 @@ func RemoveAllMembers(cfg config.Config, roomID string) error { return nil } -// getCurrentUser gets the user ID of the bot -func getCurrentUser(cfg config.Config) (string, error) { - url := fmt.Sprintf("%s/_matrix/client/v3/account/whoami", cfg.Matrix.ApiUrl) - - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Matrix.Token)) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) - } - - var response struct { - UserID string `json:"user_id"` - } - if err := json.Unmarshal(body, &response); err != nil { - return "", fmt.Errorf("failed to unmarshal response: %w", err) - } - - return response.UserID, nil -} - -// getRoomMembers gets all members of a Matrix room -func getRoomMembers(cfg config.Config, roomID string) ([]MemberEvent, error) { - url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/members", cfg.Matrix.ApiUrl, roomID) - - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Matrix.Token)) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) - } - - var response MembersResponse - if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - return response.Chunk, nil -} - -// kickUser kicks a user from a Matrix room -func kickUser(cfg config.Config, roomID, userID string) error { - url := fmt.Sprintf("%s/_matrix/client/r0/rooms/%s/kick", cfg.Matrix.ApiUrl, roomID) - - kickReq := KickRequest{ - UserID: userID, - Reason: "Room is being deleted", - } - - jsonData, err := json.Marshal(kickReq) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Matrix.Token)) - - client := &http.Client{} - resp, err := client.Do(req) +// LeaveRoom leaves a Matrix room +func LeaveRoom(cfg config.Config, roomID string) error { + client, err := getClient(cfg) if err != nil { - return fmt.Errorf("failed to send request: %w", err) + return err } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + _, err = client.LeaveRoom(context.Background(), id.RoomID(roomID)) if err != nil { - return fmt.Errorf("failed to read response: %w", err) + return fmt.Errorf("failed to leave room: %w", err) } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) - } + log.WithField("room_id", roomID).Info("left Matrix room") return nil } -// LeaveRoom leaves a Matrix room -func LeaveRoom(cfg config.Config, roomID string) error { - if !cfg.Matrix.Enabled() { - return fmt.Errorf("matrix bridge is not configured") - } - - url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/leave", cfg.Matrix.ApiUrl, roomID) - - req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer([]byte("{}"))) +func SendMessage(cfg config.Config, roomID, message string) error { + client, err := getClient(cfg) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return err } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Matrix.Token)) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) + _, err = client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgText, + Body: message, + }) if err != nil { - return fmt.Errorf("failed to read response: %w", err) + return fmt.Errorf("failed to send message: %w", err) } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) - } - - log.WithField("room_id", roomID).Info("left Matrix room") + log.WithField("room_id", roomID).Info("sent message to Matrix room") return nil } diff --git a/internal/matrix/matrix_test.go b/internal/matrix/matrix_test.go index bbecead6..aa95667e 100644 --- a/internal/matrix/matrix_test.go +++ b/internal/matrix/matrix_test.go @@ -1,87 +1,105 @@ package matrix import ( -"encoding/json" -"net/http" -"net/http/httptest" -"testing" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" -"github.com/stretchr/testify/assert" -"github.com/systemli/ticker/internal/config" + "github.com/stretchr/testify/assert" + "github.com/systemli/ticker/internal/config" + "github.com/systemli/ticker/internal/storage" ) func TestCreateRoom(t *testing.T) { -// Mock server to simulate Synapse API -server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// Verify request method and path -assert.Equal(t, http.MethodPost, r.Method) -assert.Equal(t, "/_matrix/client/v3/createRoom", r.URL.Path) + // Mock server to simulate Matrix API + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/_matrix/client/v3/createRoom" { + // Verify request method and headers + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) -// Verify authorization header -assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) -assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + // Send response + response := map[string]string{ + "room_id": "!test-room-id:example.com", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + } else if r.URL.Path == "/_matrix/client/v3/rooms/!test-room-id:example.com/state/m.room.canonical_alias/" { + // Return canonical alias (mautrix uses trailing slash) + response := map[string]string{ + "alias": "#test-room:example.com", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + } else { + // Return empty response for other requests + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + } + })) + defer server.Close() -// Verify request body -var req CreateRoomRequest -err := json.NewDecoder(r.Body).Decode(&req) -assert.NoError(t, err) -assert.Equal(t, "test-room", req.Name) -assert.Equal(t, "public_chat", req.Preset) -assert.Equal(t, "public", req.Visibility) -assert.Equal(t, "test-room", req.RoomAliasName) + // Create config + cfg := config.Config{ + Matrix: config.Matrix{ + ApiUrl: server.URL, + Token: "test-token", + }, + } -// Send response -response := CreateRoomResponse{ -RoomID: "!test-room-id:example.com", -} -w.Header().Set("Content-Type", "application/json") -w.WriteHeader(http.StatusOK) -json.NewEncoder(w).Encode(response) -})) -defer server.Close() - -// Create config -cfg := config.Config{ -Matrix: config.Matrix{ -ApiUrl: server.URL, -Token: "test-token", -}, -} + // Create a ticker + ticker := &storage.Ticker{ + Title: "Test Room", + } -// Test CreateRoom -roomID, err := CreateRoom(cfg, "test-room") -assert.NoError(t, err) + // Test CreateRoom + roomID, roomName, err := CreateRoom(cfg, ticker) + assert.NoError(t, err) assert.Equal(t, "!test-room-id:example.com", roomID) + assert.NotEmpty(t, roomName) // Just verify we got something back } func TestCreateRoom_NotConfigured(t *testing.T) { -// Create config without Matrix configuration -cfg := config.Config{} + // Create config without Matrix configuration + cfg := config.Config{} -// Test CreateRoom with unconfigured bridge -_, err := CreateRoom(cfg, "test-room") -assert.Error(t, err) -assert.Contains(t, err.Error(), "matrix bridge is not configured") + ticker := &storage.Ticker{ + Title: "Test Room", + } + + // Test CreateRoom with unconfigured bridge + _, _, err := CreateRoom(cfg, ticker) + assert.Error(t, err) + assert.Contains(t, err.Error(), "matrix bridge is not configured") } func TestCreateRoom_APIError(t *testing.T) { -// Mock server that returns an error -server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -w.WriteHeader(http.StatusUnauthorized) -w.Write([]byte(`{"errcode":"M_FORBIDDEN","error":"Invalid access token"}`)) -})) -defer server.Close() + // Mock server that returns an error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"errcode":"M_FORBIDDEN","error":"Invalid access token"}`)) + })) + defer server.Close() -// Create config -cfg := config.Config{ -Matrix: config.Matrix{ -ApiUrl: server.URL, -Token: "invalid-token", -}, -} + // Create config + cfg := config.Config{ + Matrix: config.Matrix{ + ApiUrl: server.URL, + Token: "invalid-token", + }, + } + + ticker := &storage.Ticker{ + Title: "Test Room", + } -// Test CreateRoom with invalid token -_, err := CreateRoom(cfg, "test-room") -assert.Error(t, err) -assert.Contains(t, err.Error(), "API request failed with status 401") + // Test CreateRoom with invalid token + _, _, err := CreateRoom(cfg, ticker) + assert.Error(t, err) } From a7b1ce3d3c1adfbcdfc025f884ebbc7b34ef9887 Mon Sep 17 00:00:00 2001 From: doobry Date: Sun, 4 Jan 2026 17:01:48 +0100 Subject: [PATCH 7/9] Add support for sending and deleting messages via matrix bridge --- internal/bridge/matrix_room.go | 29 +++++++++++++++++++++++++---- internal/matrix/matrix.go | 28 +++++++++++++++++++++++----- internal/storage/message.go | 7 +++++++ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/internal/bridge/matrix_room.go b/internal/bridge/matrix_room.go index 583b125f..0bc9805a 100644 --- a/internal/bridge/matrix_room.go +++ b/internal/bridge/matrix_room.go @@ -25,14 +25,35 @@ func (mb *MatrixRoomBridge) Update(ticker storage.Ticker) error { return nil } -// Send sends a message to a Matrix room func (mb *MatrixRoomBridge) Send(ticker storage.Ticker, message *storage.Message) error { - // Not implemented yet + if !mb.config.Matrix.Enabled() || !ticker.Matrix.Connected() || !ticker.Matrix.Active { + return nil + } + + eventID, err := matrix.SendMessage(mb.config, ticker.Matrix.RoomID, message.Text) + if err != nil { + return err + } + + // Store the event ID so we can delete the message later + message.MatrixRoom.EventID = eventID + err = mb.storage.SaveMessage(message) + if err != nil { + return err + } + return nil } -// Delete deletes a message from a Matrix room func (mb *MatrixRoomBridge) Delete(ticker storage.Ticker, message *storage.Message) error { - // Not implemented yet + if !mb.config.Matrix.Enabled() || !ticker.Matrix.Connected() || !ticker.Matrix.Active { + return nil + } + + err := matrix.DeleteMessage(mb.config, ticker.Matrix.RoomID, message.MatrixRoom.EventID) + if err != nil { + return err + } + return nil } diff --git a/internal/matrix/matrix.go b/internal/matrix/matrix.go index 0845d6b0..b3c8aa59 100644 --- a/internal/matrix/matrix.go +++ b/internal/matrix/matrix.go @@ -219,21 +219,39 @@ func LeaveRoom(cfg config.Config, roomID string) error { return nil } -func SendMessage(cfg config.Config, roomID, message string) error { +func SendMessage(cfg config.Config, roomID, message string) (string, error) { client, err := getClient(cfg) if err != nil { - return err + return "", err } - _, err = client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, &event.MessageEventContent{ + resp, err := client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, &event.MessageEventContent{ MsgType: event.MsgText, Body: message, }) if err != nil { - return fmt.Errorf("failed to send message: %w", err) + return "", fmt.Errorf("failed to send message: %w", err) + } + + log.WithField("room_id", roomID).WithField("event_id", resp.EventID).Info("sent message to Matrix room") + + return resp.EventID.String(), nil +} + +func DeleteMessage(cfg config.Config, roomID, eventID string) error { + client, err := getClient(cfg) + if err != nil { + return err + } + + _, err = client.RedactEvent(context.Background(), id.RoomID(roomID), id.EventID(eventID), mautrix.ReqRedact{ + Reason: "Message deleted", + }) + if err != nil { + return fmt.Errorf("failed to delete message: %w", err) } - log.WithField("room_id", roomID).Info("sent message to Matrix room") + log.WithField("room_id", roomID).WithField("event_id", eventID).Info("deleted message from Matrix room") return nil } diff --git a/internal/storage/message.go b/internal/storage/message.go index ee41467c..3cbddc6b 100644 --- a/internal/storage/message.go +++ b/internal/storage/message.go @@ -20,6 +20,7 @@ type Message struct { Mastodon MastodonMeta `gorm:"serializer:json"` Bluesky BlueskyMeta `gorm:"serializer:json"` SignalGroup SignalGroupMeta `gorm:"serializer:json"` + MatrixRoom MatrixRoomMeta `gorm:"serializer:json"` } func NewMessage() Message { @@ -31,6 +32,7 @@ func (m *Message) AsMap() map[string]interface{} { mastodon, _ := json.Marshal(m.Mastodon) bluesky, _ := json.Marshal(m.Bluesky) signalGroup, _ := json.Marshal(m.SignalGroup) + matrixRoom, _ := json.Marshal(m.MatrixRoom) return map[string]interface{}{ "id": m.ID, @@ -42,6 +44,7 @@ func (m *Message) AsMap() map[string]interface{} { "mastodon": mastodon, "bluesky": bluesky, "signal_group": signalGroup, + "matrix_room": matrixRoom, } } @@ -65,6 +68,10 @@ type SignalGroupMeta struct { Timestamp int } +type MatrixRoomMeta struct { + EventID string +} + type Attachment struct { ID int `gorm:"primaryKey"` CreatedAt time.Time From 455f9887004fb926a79ca79647e6af476bf64c23 Mon Sep 17 00:00:00 2001 From: doobry Date: Sun, 4 Jan 2026 19:45:26 +0100 Subject: [PATCH 8/9] Add support for sending images via Matrix --- internal/bridge/matrix_room.go | 8 +-- internal/matrix/matrix.go | 94 +++++++++++++++++++++++++++------- internal/storage/message.go | 2 +- 3 files changed, 81 insertions(+), 23 deletions(-) diff --git a/internal/bridge/matrix_room.go b/internal/bridge/matrix_room.go index 0bc9805a..469572a8 100644 --- a/internal/bridge/matrix_room.go +++ b/internal/bridge/matrix_room.go @@ -30,13 +30,13 @@ func (mb *MatrixRoomBridge) Send(ticker storage.Ticker, message *storage.Message return nil } - eventID, err := matrix.SendMessage(mb.config, ticker.Matrix.RoomID, message.Text) + eventIDs, err := matrix.SendMessage(mb.config, mb.storage, ticker.Matrix.RoomID, message) if err != nil { return err } - // Store the event ID so we can delete the message later - message.MatrixRoom.EventID = eventID + // Store all event IDs (images + text) so we can delete them later + message.MatrixRoom.EventIDs = eventIDs err = mb.storage.SaveMessage(message) if err != nil { return err @@ -50,7 +50,7 @@ func (mb *MatrixRoomBridge) Delete(ticker storage.Ticker, message *storage.Messa return nil } - err := matrix.DeleteMessage(mb.config, ticker.Matrix.RoomID, message.MatrixRoom.EventID) + err := matrix.DeleteMessage(mb.config, ticker.Matrix.RoomID, message.MatrixRoom.EventIDs) if err != nil { return err } diff --git a/internal/matrix/matrix.go b/internal/matrix/matrix.go index b3c8aa59..2be29c5b 100644 --- a/internal/matrix/matrix.go +++ b/internal/matrix/matrix.go @@ -1,8 +1,10 @@ package matrix import ( + "bytes" "context" "fmt" + "os" "regexp" "strings" @@ -98,7 +100,7 @@ func attemptCreateRoom(client *mautrix.Client, title, roomAliasName string) (id. return "", err } - log.WithField("room_id", resp.RoomID).WithField("room_name", title).WithField("room_alias", roomAliasName).Info("Matrix room created successfully") + log.WithField("room_id", resp.RoomID).WithField("room_name", title).WithField("room_alias", roomAliasName).Debug("Matrix room created successfully") return resp.RoomID, nil } @@ -158,7 +160,7 @@ func UpdateRoomName(cfg config.Config, roomID, name string) error { return fmt.Errorf("failed to update room name: %w", err) } - log.WithField("room_id", roomID).WithField("room_name", name).Info("Matrix room name updated successfully") + log.WithField("room_id", roomID).WithField("room_name", name).Debug("Matrix room name updated successfully") return nil } @@ -194,7 +196,7 @@ func RemoveAllMembers(cfg config.Config, roomID string) error { log.WithError(err).WithField("user_id", userID).Error("failed to kick user") // Continue kicking other users even if one fails } else { - log.WithField("user_id", userID).Info("kicked user from Matrix room") + log.WithField("user_id", userID).Debug("kicked user from Matrix room") } } } @@ -214,44 +216,100 @@ func LeaveRoom(cfg config.Config, roomID string) error { return fmt.Errorf("failed to leave room: %w", err) } - log.WithField("room_id", roomID).Info("left Matrix room") + log.WithField("room_id", roomID).Debug("left Matrix room") return nil } -func SendMessage(cfg config.Config, roomID, message string) (string, error) { +func SendMessage(cfg config.Config, store storage.Storage, roomID string, message *storage.Message) ([]string, error) { client, err := getClient(cfg) if err != nil { - return "", err + return nil, err } + var eventIDs []string + + // Send image attachments first (one per message in Matrix) + for _, attachment := range message.Attachments { + upload, err := store.FindUploadByUUID(attachment.UUID) + if err != nil { + log.WithError(err).WithField("uuid", attachment.UUID).Error("failed to find upload") + continue + } + + // Read file from disk + fileContent, err := os.ReadFile(upload.FullPath(cfg.Upload.Path)) + if err != nil { + log.WithError(err).WithField("path", upload.FullPath(cfg.Upload.Path)).Error("failed to read file") + continue + } + + // Upload file to Matrix + uploadResp, err := client.UploadMedia(context.Background(), mautrix.ReqUploadMedia{ + Content: bytes.NewReader(fileContent), + ContentType: upload.ContentType, + FileName: upload.FileName(), + }) + if err != nil { + log.WithError(err).WithField("file", upload.FileName()).Error("failed to upload file to Matrix") + continue + } + + // Send image message + imageResp, err := client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: upload.FileName(), + URL: uploadResp.ContentURI.CUString(), + Info: &event.FileInfo{ + MimeType: upload.ContentType, + Size: len(fileContent), + }, + }) + if err != nil { + log.WithError(err).WithField("file", upload.FileName()).Error("failed to send image message") + continue + } + + // Store image event ID + eventIDs = append(eventIDs, imageResp.EventID.String()) + log.WithField("room_id", roomID).WithField("file", upload.FileName()).WithField("event_id", imageResp.EventID).Debug("sent image to Matrix room") + } + + // Send text message last resp, err := client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, &event.MessageEventContent{ MsgType: event.MsgText, - Body: message, + Body: message.Text, }) if err != nil { - return "", fmt.Errorf("failed to send message: %w", err) + return nil, fmt.Errorf("failed to send message: %w", err) } - log.WithField("room_id", roomID).WithField("event_id", resp.EventID).Info("sent message to Matrix room") + // Store text message event ID + eventIDs = append(eventIDs, resp.EventID.String()) + log.WithField("room_id", roomID).WithField("event_id", resp.EventID).Debug("sent message to Matrix room") - return resp.EventID.String(), nil + return eventIDs, nil } -func DeleteMessage(cfg config.Config, roomID, eventID string) error { +// DeleteMessage redacts multiple messages (images + text) from a Matrix room +func DeleteMessage(cfg config.Config, roomID string, eventIDs []string) error { client, err := getClient(cfg) if err != nil { return err } - _, err = client.RedactEvent(context.Background(), id.RoomID(roomID), id.EventID(eventID), mautrix.ReqRedact{ - Reason: "Message deleted", - }) - if err != nil { - return fmt.Errorf("failed to delete message: %w", err) + // Redact all event IDs (images and text message) + for _, eventID := range eventIDs { + _, err = client.RedactEvent(context.Background(), id.RoomID(roomID), id.EventID(eventID), mautrix.ReqRedact{ + Reason: "Message deleted", + }) + if err != nil { + log.WithError(err).WithField("event_id", eventID).Error("failed to redact event") + // Continue with other events even if one fails + continue + } + log.WithField("room_id", roomID).WithField("event_id", eventID).Debug("deleted message from Matrix room") } - log.WithField("room_id", roomID).WithField("event_id", eventID).Info("deleted message from Matrix room") - return nil } diff --git a/internal/storage/message.go b/internal/storage/message.go index 3cbddc6b..8f8db06c 100644 --- a/internal/storage/message.go +++ b/internal/storage/message.go @@ -69,7 +69,7 @@ type SignalGroupMeta struct { } type MatrixRoomMeta struct { - EventID string + EventIDs []string } type Attachment struct { From 740e7b56cc1b487b9df199196d403020bdd30b3d Mon Sep 17 00:00:00 2001 From: doobry Date: Sun, 4 Jan 2026 20:03:35 +0100 Subject: [PATCH 9/9] Remove room encryption for now It would be complex to implement end-to-end encryption for sending messages and there's no big point in doing so for a public room anyways. --- internal/matrix/matrix.go | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/internal/matrix/matrix.go b/internal/matrix/matrix.go index 2be29c5b..943c54a1 100644 --- a/internal/matrix/matrix.go +++ b/internal/matrix/matrix.go @@ -81,20 +81,11 @@ func attemptCreateRoom(client *mautrix.Client, title, roomAliasName string) (id. RoomAliasName: roomAliasName, Preset: "public_chat", Visibility: "public", + // Adjust room permissions to allow anyone to invite and only moderators and above to send messages PowerLevelOverride: &event.PowerLevelsEventContent{ InvitePtr: &inviteLevel, EventsDefault: 50, }, - InitialState: []*event.Event{ - { - Type: event.StateEncryption, - Content: event.Content{ - Parsed: &event.EncryptionEventContent{ - Algorithm: id.AlgorithmMegolmV1, - }, - }, - }, - }, }) if err != nil { return "", err