This commit is contained in:
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user