initial commit
Build and Publish Package (amd64) / docker (push) Failing after 1m28s

This commit is contained in:
2026-07-25 17:15:24 +02:00
commit 9acd7406e9
10 changed files with 848 additions and 0 deletions
+58
View File
@@ -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
+66
View File
@@ -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
+20
View File
@@ -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"]
+77
View File
@@ -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
}
+106
View File
@@ -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
}
+27
View File
@@ -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
)
+47
View File
@@ -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=
+84
View File
@@ -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
}
+205
View File
@@ -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 <topic_name> — 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 <topic_name>")
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)
}
}
}
+158
View File
@@ -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
}