From 9acd7406e982518c2e0277bdfed551389457057e Mon Sep 17 00:00:00 2001 From: jzitnik-dev Date: Sat, 25 Jul 2026 17:15:24 +0200 Subject: [PATCH] initial commit --- .gitea/workflows/build-and-push.yaml | 58 ++++++++ .gitignore | 66 +++++++++ Dockerfile | 20 +++ config.go | 77 ++++++++++ database.go | 106 ++++++++++++++ go.mod | 27 ++++ go.sum | 47 ++++++ main.go | 84 +++++++++++ matrix_handler.go | 205 +++++++++++++++++++++++++++ ntfy_client.go | 158 +++++++++++++++++++++ 10 files changed, 848 insertions(+) create mode 100644 .gitea/workflows/build-and-push.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 config.go create mode 100644 database.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 matrix_handler.go create mode 100644 ntfy_client.go diff --git a/.gitea/workflows/build-and-push.yaml b/.gitea/workflows/build-and-push.yaml new file mode 100644 index 0000000..0c98ebf --- /dev/null +++ b/.gitea/workflows/build-and-push.yaml @@ -0,0 +1,58 @@ +name: Build and Publish Package (amd64) + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + packages: write + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: gitea.jzitnik.dev + username: ${{ gitea.actor }} + password: ${{ secrets.TOKEN }} + + - name: Convert Owner and Repo to Lowercase + id: string_prep + run: | + FULL_REPO="${GITHUB_REPOSITORY,,}" + OWNER="${FULL_REPO%%/*}" + REPO="${FULL_REPO#*/}" + echo "owner=$OWNER" >> $GITHUB_OUTPUT + echo "repo=$REPO" >> $GITHUB_OUTPUT + + - name: Extract Metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: gitea.jzitnik.dev/${{ steps.string_prep.outputs.owner }}/${{ steps.string_prep.outputs.repo }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=sha,format=short + + - name: Build and Push (amd64 Fast Path) + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ad6b9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# ========================================== +# Application Specific / Local State & Secrets +# ========================================== +# Ignore local config files containing actual tokens/secrets +config.yaml +!config.example.yaml + +# Ignore generated Appservice registration files +registration.yaml + +# Local SQLite databases and logs +*.db +*.db-journal +*.sqlite +*.sqlite3 +ntfy-bridge.db +*.log + +# Environment variables +.env +.env.* + +# ========================================== +# Compiled Go Binaries & Build Artifacts +# ========================================== +# Main binary output +mautrix-ntfy + +# General Go build output +*.exe +*.exe? +*.dll +*.so +*.dylib +*.test +*.out + +# Go vendoring (if not explicitly committed) +# vendor/ + +# ========================================== +# IDEs & System Files +# ========================================== +# macOS / OS X +.DS_Store +.Trashes +*.lsstream + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# Visual Studio Code +.vscode/ +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# JetBrains (GoLand, IntelliJ) +.idea/ +*.iws +*.iml +*.ipr diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0a83be6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git ca-certificates + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o mautrix-ntfy main.go + +FROM alpine:latest + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /data +COPY --from=builder /app/mautrix-ntfy /usr/local/bin/mautrix-ntfy + +ENTRYPOINT ["mautrix-ntfy"] +CMD ["-config", "/data/config.yaml", "-database", "/data/ntfy-bridge.db"] diff --git a/config.go b/config.go new file mode 100644 index 0000000..2d9ef21 --- /dev/null +++ b/config.go @@ -0,0 +1,77 @@ +package main + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type HomeserverConfig struct { + Domain string `yaml:"domain"` + URL string `yaml:"url"` +} + +type AppserviceConfig struct { + ID string `yaml:"id"` + ASToken string `yaml:"as_token"` + HSToken string `yaml:"hs_token"` + BotLocalpart string `yaml:"bot_localpart"` + Address string `yaml:"address"` + Port uint16 `yaml:"port"` +} + +type BridgeConfig struct { + NtfyURL string `yaml:"ntfy_url"` + Permissions map[string]string `yaml:"permissions"` +} + +type Config struct { + Homeserver HomeserverConfig `yaml:"homeserver"` + Appservice AppserviceConfig `yaml:"appservice"` + Bridge BridgeConfig `yaml:"bridge"` +} + +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + cfg := &Config{} + if err = yaml.Unmarshal(data, cfg); err != nil { + return nil, err + } + if cfg.Bridge.Permissions == nil { + cfg.Bridge.Permissions = make(map[string]string) + } + if cfg.Bridge.NtfyURL == "" { + cfg.Bridge.NtfyURL = "https://ntfy.sh" + } + if cfg.Appservice.Address == "" { + cfg.Appservice.Address = "0.0.0.0" + } + if cfg.Appservice.Port == 0 { + cfg.Appservice.Port = 8080 + } + return cfg, nil +} + +func (b *BridgeConfig) PermissionLevel(id string) string { + if level, ok := b.Permissions[id]; ok { + return level + } + return "" +} + +func (b *BridgeConfig) HasPermission(userID, requiredLevel string) bool { + level := b.PermissionLevel(userID) + if level == "" { + level = b.PermissionLevel("*") + } + if level == "" { + return false + } + if requiredLevel == "admin" { + return level == "admin" + } + return true +} diff --git a/database.go b/database.go new file mode 100644 index 0000000..241911a --- /dev/null +++ b/database.go @@ -0,0 +1,106 @@ +package main + +import ( + "database/sql" + + _ "github.com/mattn/go-sqlite3" +) + +type Database struct { + db *sql.DB +} + +func NewDatabase(path string) (*Database, error) { + db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + return nil, err + } + d := &Database{db: db} + if err = d.migrate(); err != nil { + db.Close() + return nil, err + } + return d, nil +} + +func (d *Database) Close() error { + return d.db.Close() +} + +func (d *Database) migrate() error { + for _, q := range []string{ + `CREATE TABLE IF NOT EXISTS spaces ( + user_mxid TEXT PRIMARY KEY, + space_room_id TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS bridges ( + ntfy_topic TEXT PRIMARY KEY, + matrix_room_id TEXT NOT NULL + )`, + } { + if _, err := d.db.Exec(q); err != nil { + return err + } + } + return nil +} + +func (d *Database) GetSpaceRoomID(userMXID string) (string, error) { + var roomID string + err := d.db.QueryRow("SELECT space_room_id FROM spaces WHERE user_mxid = ?", userMXID).Scan(&roomID) + if err == sql.ErrNoRows { + return "", nil + } + return roomID, err +} + +func (d *Database) SetSpaceRoomID(userMXID, spaceRoomID string) error { + _, err := d.db.Exec( + "INSERT OR REPLACE INTO spaces (user_mxid, space_room_id) VALUES (?, ?)", + userMXID, spaceRoomID, + ) + return err +} + +func (d *Database) GetAllBridges() (map[string]string, error) { + rows, err := d.db.Query("SELECT ntfy_topic, matrix_room_id FROM bridges") + if err != nil { + return nil, err + } + defer rows.Close() + bridges := make(map[string]string) + for rows.Next() { + var topic, roomID string + if err := rows.Scan(&topic, &roomID); err != nil { + return nil, err + } + bridges[topic] = roomID + } + return bridges, rows.Err() +} + +func (d *Database) GetBridgeByRoom(matrixRoomID string) (string, error) { + var topic string + err := d.db.QueryRow("SELECT ntfy_topic FROM bridges WHERE matrix_room_id = ?", matrixRoomID).Scan(&topic) + if err == sql.ErrNoRows { + return "", nil + } + return topic, err +} + +func (d *Database) GetBridgeByTopic(ntfyTopic string) (string, error) { + var roomID string + err := d.db.QueryRow("SELECT matrix_room_id FROM bridges WHERE ntfy_topic = ?", ntfyTopic).Scan(&roomID) + if err == sql.ErrNoRows { + return "", nil + } + return roomID, err +} + +func (d *Database) SetBridge(ntfyTopic, matrixRoomID string) error { + _, err := d.db.Exec( + "INSERT OR REPLACE INTO bridges (ntfy_topic, matrix_room_id) VALUES (?, ?)", + ntfyTopic, matrixRoomID, + ) + return err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..647618e --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module mautrix-ntfy + +go 1.26.5 + +require ( + github.com/mattn/go-sqlite3 v1.14.48 + gopkg.in/yaml.v3 v3.0.1 + maunium.net/go/mautrix v0.29.0 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/coder/websocket v1.8.15 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/tidwall/gjson v1.19.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 + go.mau.fi/util v0.9.11 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..895d5c3 --- /dev/null +++ b/go.sum @@ -0,0 +1,47 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= +github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +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/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +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.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +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/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= +go.mau.fi/util v0.9.11 h1:Cus1Lu/t7d3OG6VF4aYWvlUUS0Q4O1/lcpPNJZ0jsw0= +go.mau.fi/util v0.9.11/go.mod h1:xunp/oIQfFD68HHcNHfG0pOiHkvEtDhTweeIwKJ//+Q= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g= +golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maunium.net/go/mautrix v0.29.0 h1:OkcBJF1dvp+93EgahxMxOUZZOrGTYculI9IprvRIMOQ= +maunium.net/go/mautrix v0.29.0/go.mod h1:LynuVr8N9nWsE1N4WAE+vItRACDB1pt9M3gN4SIBpeY= diff --git a/main.go b/main.go new file mode 100644 index 0000000..1f3d933 --- /dev/null +++ b/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/event" +) + +func main() { + configPath := flag.String("config", "config.yaml", "Path to configuration file") + dbPath := flag.String("database", "ntfy-bridge.db", "Path to SQLite database file") + flag.Parse() + + cfg, err := LoadConfig(*configPath) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + db, err := NewDatabase(*dbPath) + if err != nil { + log.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + reg := &appservice.Registration{ + ID: cfg.Appservice.ID, + AppToken: cfg.Appservice.ASToken, + ServerToken: cfg.Appservice.HSToken, + SenderLocalpart: cfg.Appservice.BotLocalpart, + } + + as, err := appservice.CreateFull(appservice.CreateOpts{ + Registration: reg, + HomeserverDomain: cfg.Homeserver.Domain, + HomeserverURL: cfg.Homeserver.URL, + HostConfig: appservice.HostConfig{Hostname: cfg.Appservice.Address, Port: cfg.Appservice.Port}, + }) + if err != nil { + log.Fatalf("Failed to create appservice: %v", err) + } + + ghostPrefix := fmt.Sprintf("@%s_", cfg.Appservice.BotLocalpart) + + handler := &MatrixHandler{ + AS: as, + DB: db, + Ntfy: NewNtfyClient(cfg.Bridge.NtfyURL), + Config: &cfg.Bridge, + HSDomain: cfg.Homeserver.Domain, + GhostPrefix: ghostPrefix, + } + + ep := appservice.NewEventProcessor(as) + ep.On(event.EventMessage, handler.HandleEvent) + ep.On(event.StateMember, handler.HandleEvent) + + bridges, err := db.GetAllBridges() + if err != nil { + log.Fatalf("Failed to load bridges: %v", err) + } + + botIntent := as.BotIntent() + for topic, roomID := range bridges { + log.Printf("[startup] Re-establishing ntfy listener for topic %q -> room %s", topic, roomID) + handler.Ntfy.StartNtfyListener(topic, roomID, botIntent, as.BotMXID()) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ep.Start(ctx) + + log.Printf("[main] Starting appservice on %s:%d", cfg.Appservice.Address, cfg.Appservice.Port) + log.Printf("[main] ntfy base URL: %s", cfg.Bridge.NtfyURL) + log.Printf("[main] Loaded %d existing bridge(s)", len(bridges)) + as.Start() + + _ = os.Stdout +} diff --git a/matrix_handler.go b/matrix_handler.go new file mode 100644 index 0000000..f95d2fc --- /dev/null +++ b/matrix_handler.go @@ -0,0 +1,205 @@ +package main + +import ( + "context" + "fmt" + "log" + "strings" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type MatrixHandler struct { + AS *appservice.AppService + DB *Database + Ntfy *NtfyClient + Config *BridgeConfig + HSDomain string + GhostPrefix string +} + +func (h *MatrixHandler) HandleEvent(ctx context.Context, evt *event.Event) { + if evt.Sender == h.AS.BotMXID() { + return + } + if h.GhostPrefix != "" && strings.HasPrefix(string(evt.Sender), h.GhostPrefix) { + return + } + + switch evt.Type { + case event.EventMessage: + h.handleMessage(ctx, evt) + case event.StateMember: + h.handleMembership(ctx, evt) + } +} + +func (h *MatrixHandler) handleMessage(ctx context.Context, evt *event.Event) { + msg := evt.Content.AsMessage() + if msg == nil { + return + } + + text := strings.TrimSpace(msg.Body) + + if strings.HasPrefix(text, "bridge ") { + h.handleCommand(ctx, evt, text) + return + } + + topic, err := h.DB.GetBridgeByRoom(string(evt.RoomID)) + if err != nil { + log.Printf("[matrix] db lookup error for room %s: %v", evt.RoomID, err) + return + } + if topic == "" { + return + } + + if err := h.Ntfy.PublishToNtfy(topic, text); err != nil { + botIntent := h.AS.BotIntent() + botIntent.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Failed to publish to ntfy: %v", err)) + log.Printf("[matrix] publish to ntfy failed: %v", err) + } +} + +func (h *MatrixHandler) handleCommand(ctx context.Context, evt *event.Event, text string) { + parts := strings.SplitN(text, " ", 3) + if len(parts) < 2 { + return + } + + switch parts[1] { + case "create": + topicName := "" + if len(parts) > 2 { + topicName = strings.TrimSpace(parts[2]) + } + h.handleCreateBridge(ctx, evt, topicName) + default: + botIntent := h.AS.BotIntent() + botIntent.SendNotice(ctx, evt.RoomID, "Available commands:\n bridge create — Bridge an ntfy topic to a new Matrix room") + } +} + +func (h *MatrixHandler) handleCreateBridge(ctx context.Context, evt *event.Event, topicName string) { + botIntent := h.AS.BotIntent() + + if topicName == "" { + botIntent.SendNotice(ctx, evt.RoomID, "Usage: bridge create ") + return + } + + if !h.Config.HasPermission(string(evt.Sender), "admin") { + botIntent.SendNotice(ctx, evt.RoomID, "You do not have permission to create bridges. Admin access required.") + return + } + + existing, err := h.DB.GetBridgeByTopic(topicName) + if err != nil { + log.Printf("[matrix] db error checking topic: %v", err) + botIntent.SendNotice(ctx, evt.RoomID, "Internal database error.") + return + } + if existing != "" { + botIntent.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Topic '%s' is already bridged to room %s.", topicName, existing)) + return + } + + spaceRoomID, err := h.DB.GetSpaceRoomID(string(evt.Sender)) + if err != nil { + log.Printf("[matrix] db error checking space: %v", err) + botIntent.SendNotice(ctx, evt.RoomID, "Internal database error.") + return + } + + if spaceRoomID == "" { + resp, err := botIntent.Client.CreateRoom(ctx, &mautrix.ReqCreateRoom{ + Name: "Notifications", + Preset: "private_chat", + CreationContent: map[string]interface{}{ + "type": "m.space", + }, + Invite: []id.UserID{evt.Sender}, + }) + if err != nil { + botIntent.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Failed to create Notifications space: %v", err)) + return + } + spaceRoomID = string(resp.RoomID) + + if err := h.DB.SetSpaceRoomID(string(evt.Sender), spaceRoomID); err != nil { + log.Printf("[matrix] failed to save space: %v", err) + botIntent.SendNotice(ctx, evt.RoomID, "Failed to save space reference to database.") + return + } + + userIntent := h.AS.Intent(evt.Sender) + userIntent.IsCustomPuppet = true + if err := userIntent.EnsureJoined(ctx, id.RoomID(spaceRoomID)); err != nil { + log.Printf("[matrix] double puppet join space failed: %v", err) + } + + botIntent.SendNotice(ctx, evt.RoomID, "Created 'Notifications' space.") + } + + roomResp, err := botIntent.Client.CreateRoom(ctx, &mautrix.ReqCreateRoom{ + Name: topicName, + Topic: fmt.Sprintf("ntfy topic: %s", topicName), + Preset: "private_chat", + Invite: []id.UserID{evt.Sender}, + }) + if err != nil { + botIntent.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Failed to create bridged room: %v", err)) + return + } + bridgeRoomID := roomResp.RoomID + + viaServers := []string{h.HSDomain} + if _, err := botIntent.SendStateEvent(ctx, id.RoomID(spaceRoomID), event.StateSpaceChild, string(bridgeRoomID), &event.SpaceChildEventContent{ + Via: viaServers, + }); err != nil { + log.Printf("[matrix] failed to add room to space: %v", err) + } + + userIntent := h.AS.Intent(evt.Sender) + userIntent.IsCustomPuppet = true + if err := userIntent.EnsureJoined(ctx, bridgeRoomID); err != nil { + log.Printf("[matrix] double puppet join bridge room failed: %v", err) + } + + if err := h.DB.SetBridge(topicName, string(bridgeRoomID)); err != nil { + log.Printf("[matrix] failed to save bridge: %v", err) + botIntent.SendNotice(ctx, evt.RoomID, "Bridge created but failed to save to database.") + return + } + + h.Ntfy.StartNtfyListener(topicName, string(bridgeRoomID), h.AS.BotIntent(), h.AS.BotMXID()) + + botIntent.SendNotice(ctx, evt.RoomID, fmt.Sprintf( + "Bridge created!\n• Topic: %s\n• Room: %s\nMessages sent in that room will be published to ntfy, and ntfy messages will appear there.", + topicName, bridgeRoomID, + )) +} + +func (h *MatrixHandler) handleMembership(ctx context.Context, evt *event.Event) { + member := evt.Content.AsMember() + if member == nil { + return + } + + stateKey := evt.GetStateKey() + if stateKey == "" { + return + } + + if member.Membership == event.MembershipInvite && id.UserID(stateKey) == h.AS.BotMXID() { + botIntent := h.AS.BotIntent() + if err := botIntent.EnsureJoined(ctx, evt.RoomID); err != nil { + log.Printf("[matrix] failed to auto-join room %s: %v", evt.RoomID, err) + } + } +} diff --git a/ntfy_client.go b/ntfy_client.go new file mode 100644 index 0000000..1850354 --- /dev/null +++ b/ntfy_client.go @@ -0,0 +1,158 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/id" +) + +type NtfyEvent struct { + ID string `json:"id"` + Time int64 `json:"time"` + Event string `json:"event"` + Topic string `json:"topic"` + Message string `json:"message"` + Title string `json:"title"` +} + +type NtfyClient struct { + BaseURL string +} + +func NewNtfyClient(baseURL string) *NtfyClient { + return &NtfyClient{BaseURL: strings.TrimRight(baseURL, "/")} +} + +func (c *NtfyClient) PublishToNtfy(topic, message string) error { + url := fmt.Sprintf("%s/%s", c.BaseURL, topic) + + req, err := http.NewRequest("POST", url, bytes.NewReader([]byte(message))) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "text/plain") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("publish request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("ntfy returned %d: %s", resp.StatusCode, string(body)) + } + return nil +} + +func (c *NtfyClient) StartNtfyListener( + topic, + matrixRoomID string, + intent *appservice.IntentAPI, + botUserID id.UserID, +) { + go c.listenLoop(topic, matrixRoomID, intent, botUserID) +} + +func (c *NtfyClient) listenLoop( + topic, + matrixRoomID string, + intent *appservice.IntentAPI, + botUserID id.UserID, +) { + backoff := 1 * time.Second + const maxBackoff = 2 * time.Minute + + for { + err := c.stream(topic, matrixRoomID, intent, botUserID) + if err != nil { + log.Printf("[ntfy] stream for topic %q disconnected: %v, reconnecting in %v", topic, err, backoff) + } else { + log.Printf("[ntfy] stream for topic %q closed cleanly, reconnecting in %v", topic, backoff) + backoff = 1 * time.Second + } + time.Sleep(backoff) + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } +} + +func (c *NtfyClient) stream( + topic, + matrixRoomID string, + intent *appservice.IntentAPI, + botUserID id.UserID, +) error { + url := fmt.Sprintf("%s/%s/json", c.BaseURL, topic) + + client := &http.Client{Timeout: 0} + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("GET %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s returned %d", url, resp.StatusCode) + } + + ctx := context.Background() + scanner := bufio.NewScanner(resp.Body) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var ntfyEvt NtfyEvent + if err := json.Unmarshal(line, &ntfyEvt); err != nil { + continue + } + + if ntfyEvt.Event != "message" { + continue + } + + body := ntfyEvt.Message + if body == "" { + continue + } + if ntfyEvt.Title != "" { + body = fmt.Sprintf("**%s**\n%s", ntfyEvt.Title, body) + } + + if _, err := intent.SendText(ctx, id.RoomID(matrixRoomID), body); err != nil { + log.Printf("[ntfy] failed to send to Matrix room %s: %v", matrixRoomID, err) + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("scanner error: %w", err) + } + return nil +} + +func (c *NtfyClient) IsBridgingBack(sender id.UserID, botUserID id.UserID, ghostPrefix string) bool { + if sender == botUserID { + return true + } + if ghostPrefix != "" && strings.HasPrefix(string(sender), ghostPrefix) { + return true + } + return false +}