commit 9acd7406e982518c2e0277bdfed551389457057e Author: jzitnik-dev Date: Sat Jul 25 17:15:24 2026 +0200 initial commit 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 +}