Add Arrtrix runtime, config, onboarding, and webhook support

- Implement runtime package for bridge startup, config loading, and env
  overrides
- Add onboarding package for management room welcome messages
- Add matrixcmd package for command processing and help
- Add webhook package with Radarr webhook support and validation
- Extend connector config for webhooks and validation
- Update default config and example config for new options
- Add tests for new packages and config validation
- Change database type default to sqlite3-fk-wal
This commit is contained in:
Chris Kruining 2026-04-16 09:06:57 +02:00
parent eeedb5268a
commit fe627f3aab
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
19 changed files with 1855 additions and 35 deletions

View file

@ -0,0 +1,206 @@
package runtime
import (
"fmt"
"os"
"reflect"
"strconv"
"strings"
)
const fileEnvPrefix = "READFILE:"
func updateConfigFromEnv(cfg, networkData any, prefix string) error {
if prefix == "" {
return nil
}
cfgVal := reflect.ValueOf(cfg)
networkVal := reflect.ValueOf(networkData)
for _, env := range os.Environ() {
if !strings.HasPrefix(env, prefix) {
continue
}
keyValue := strings.SplitN(env, "=", 2)
if len(keyValue) != 2 {
continue
}
key := strings.TrimPrefix(keyValue[0], prefix)
value := keyValue[1]
if strings.HasSuffix(key, "_FILE") {
key = strings.TrimSuffix(key, "_FILE")
value = fileEnvPrefix + value
}
key = strings.ToLower(key)
if !strings.ContainsRune(key, '.') {
key = strings.ReplaceAll(key, "__", ".")
}
path := strings.Split(key, ".")
field, ok := reflectGetFromMainOrNetwork(cfgVal, networkVal, path)
if !ok {
return fmt.Errorf("%s not found", formatKey(path))
}
if strings.HasPrefix(value, fileEnvPrefix) {
filePath := strings.TrimPrefix(value, fileEnvPrefix)
fileData, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file %s for %s: %w", filePath, formatKey(path), err)
}
value = strings.TrimSpace(string(fileData))
}
if err := setReflectedValue(field, path, value); err != nil {
return err
}
}
return nil
}
type reflectedField struct {
value reflect.Value
valueKind reflect.Kind
remainingPath []string
}
func formatKey(path []string) string {
return strings.Join(path, "->")
}
func reflectGetFromMainOrNetwork(main, network reflect.Value, path []string) (*reflectedField, bool) {
if len(path) > 0 && path[0] == "network" {
return reflectGetYAML(network, path[1:])
}
return reflectGetYAML(main, path)
}
func reflectGetYAML(value reflect.Value, path []string) (*reflectedField, bool) {
if len(path) == 0 {
return &reflectedField{value: value, valueKind: value.Kind()}, true
}
if value.Kind() == reflect.Ptr {
value = value.Elem()
}
switch value.Kind() {
case reflect.Map:
return &reflectedField{
value: value,
valueKind: value.Type().Elem().Kind(),
remainingPath: path,
}, true
case reflect.Struct:
fields := reflect.VisibleFields(value.Type())
for _, field := range fields {
if yamlFieldName(field) != path[0] {
continue
}
return reflectGetYAML(value.FieldByIndex(field.Index), path[1:])
}
}
return nil, false
}
func yamlFieldName(field reflect.StructField) string {
parts := strings.SplitN(field.Tag.Get("yaml"), ",", 2)
switch name := parts[0]; {
case name == "-" && len(parts) == 1:
return ""
case name == "":
return strings.ToLower(field.Name)
default:
return name
}
}
func setReflectedValue(field *reflectedField, path []string, raw string) error {
parsed, err := parseValue(field.valueKind, raw, path)
if err != nil {
return err
}
value := field.value
if value.Kind() == reflect.Ptr {
if value.IsNil() {
value.Set(reflect.New(value.Type().Elem()))
}
value = value.Elem()
}
if value.Kind() == reflect.Map {
if value.Type().Key().Kind() != reflect.String {
return fmt.Errorf("unsupported map key type %s in %s", value.Type().Key().Kind(), formatKey(path))
}
key := strings.Join(field.remainingPath, ".")
value.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(parsed))
return nil
}
value.Set(reflect.ValueOf(parsed))
return nil
}
func parseValue(kind reflect.Kind, raw string, path []string) (any, error) {
switch kind {
case reflect.String:
return raw, nil
case reflect.Bool:
parsed, err := strconv.ParseBool(raw)
if err != nil {
return nil, fmt.Errorf("invalid value for %s: %w", formatKey(path), err)
}
return parsed, nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid value for %s: %w", formatKey(path), err)
}
switch kind {
case reflect.Int8:
return int8(parsed), nil
case reflect.Int16:
return int16(parsed), nil
case reflect.Int32:
return int32(parsed), nil
case reflect.Int64:
return parsed, nil
default:
return int(parsed), nil
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
parsed, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid value for %s: %w", formatKey(path), err)
}
switch kind {
case reflect.Uint8:
return uint8(parsed), nil
case reflect.Uint16:
return uint16(parsed), nil
case reflect.Uint32:
return uint32(parsed), nil
case reflect.Uint64:
return parsed, nil
default:
return uint(parsed), nil
}
case reflect.Float32, reflect.Float64:
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
return nil, fmt.Errorf("invalid value for %s: %w", formatKey(path), err)
}
if kind == reflect.Float32 {
return float32(parsed), nil
}
return parsed, nil
default:
return nil, fmt.Errorf("unsupported type %s in %s", kind, formatKey(path))
}
}

View file

@ -0,0 +1,69 @@
package runtime
import (
"fmt"
"strings"
"maunium.net/go/mautrix/bridgev2"
)
func makeExampleConfig(networkName bridgev2.BridgeName, networkExample string) string {
var builder strings.Builder
builder.WriteString("# Network-specific config options\n")
builder.WriteString("network:\n")
for _, line := range strings.Split(strings.TrimRight(networkExample, "\n"), "\n") {
if line == "" {
builder.WriteString(" \n")
continue
}
builder.WriteString(" ")
builder.WriteString(line)
builder.WriteByte('\n')
}
builder.WriteByte('\n')
builder.WriteString(fmt.Sprintf(`bridge:
command_prefix: "%s"
permissions:
"*": relay
"@admin:example.com": admin
database:
type: sqlite3-fk-wal
uri: file:arrtrix.db?_txlock=immediate
homeserver:
address: http://example.localhost:8008
domain: example.com
software: standard
appservice:
address: http://localhost:%d
hostname: 127.0.0.1
port: %d
id: %s
bot:
username: %s
displayname: %s
as_token: This value is generated when generating the registration
hs_token: This value is generated when generating the registration
username_template: %s_{{.}}
logging:
min_level: info
writers:
- type: stdout
format: pretty-colored
management_room_texts:
welcome: ""
welcome_connected: ""
welcome_unconnected: ""
additional_help: ""
env_config_prefix: ""
`, networkName.DefaultCommandPrefix, networkName.DefaultPort, networkName.DefaultPort, networkName.NetworkID, "arrtrixbot", "Arrtrix Bot", networkName.NetworkID))
return builder.String()
}

View file

@ -0,0 +1,418 @@
package runtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"runtime"
"strings"
"syscall"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog"
"go.mau.fi/util/progver"
"gopkg.in/yaml.v3"
flag "maunium.net/go/mauflag"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/bridgev2/commands"
"maunium.net/go/mautrix/bridgev2/matrix"
"maunium.net/go/mautrix/event"
arrconfig "sneeuwvlok/packages/arrtrix/pkg/config"
"sneeuwvlok/packages/arrtrix/pkg/matrixcmd"
"sneeuwvlok/packages/arrtrix/pkg/onboarding"
)
var configPath = flag.MakeFull("c", "config", "The path to your config file.", "config.yaml").String()
var writeExampleConfig = flag.MakeFull("e", "generate-example-config", "Save the example config to the config path and quit.", "false").Bool()
var dontSaveConfig = flag.MakeFull("n", "no-update", "Don't save updated config to disk.", "false").Bool()
var registrationPath = flag.MakeFull("r", "registration", "The path where to save the appservice registration.", "registration.yaml").String()
var generateRegistration = flag.MakeFull("g", "generate-registration", "Generate registration and quit.", "false").Bool()
var version = flag.MakeFull("v", "version", "View bridge version and quit.", "false").Bool()
var versionJSON = flag.Make().LongKey("version-json").Usage("Print a JSON object representing the bridge version and quit.").Default("false").Bool()
var ignoreUnsupportedDatabase = flag.Make().LongKey("ignore-unsupported-database").Usage("Run even if the database schema is too new").Default("false").Bool()
var ignoreForeignTables = flag.Make().LongKey("ignore-foreign-tables").Usage("Run even if the database contains tables from other programs (like Synapse)").Default("false").Bool()
var ignoreUnsupportedServer = flag.Make().LongKey("ignore-unsupported-server").Usage("Run even if the Matrix homeserver is outdated").Default("false").Bool()
var wantHelp, _ = flag.MakeHelpFlag()
type Main struct {
Name string
Description string
URL string
Version string
Connector bridgev2.NetworkConnector
PostInit func()
PostStart func()
Log *zerolog.Logger
DB *dbutil.Database
PublicConfig *arrconfig.Config
Config *bridgeconfig.Config
Matrix *matrix.Connector
Bridge *bridgev2.Bridge
ConfigPath string
RegistrationPath string
SaveConfig bool
ver progver.ProgramVersion
manualStop chan int
}
type versionJSONOutput struct {
progver.ProgramVersion
OS string
Arch string
Mautrix struct {
Version string
Commit string
}
}
type routeMounter interface {
MountRoutes(*http.ServeMux) error
}
func (m *Main) Run() {
m.PreInit()
m.Init()
m.Start()
exitCode := m.WaitForInterrupt()
m.Stop()
os.Exit(exitCode)
}
func (m *Main) PreInit() {
m.manualStop = make(chan int, 1)
flag.SetHelpTitles(
fmt.Sprintf("%s - %s", m.Name, m.Description),
fmt.Sprintf("%s [-hgvn] [-c <path>] [-r <path>]", m.Name),
)
err := flag.Parse()
m.ConfigPath = *configPath
m.RegistrationPath = *registrationPath
m.SaveConfig = !*dontSaveConfig
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
flag.PrintHelp()
os.Exit(1)
}
switch {
case *wantHelp:
flag.PrintHelp()
os.Exit(0)
case *version:
fmt.Println(m.ver.VersionDescription)
os.Exit(0)
case *versionJSON:
output := versionJSONOutput{
ProgramVersion: m.ver,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
output.Mautrix.Version = mautrix.Version
output.Mautrix.Commit = mautrix.Commit
_ = json.NewEncoder(os.Stdout).Encode(output)
os.Exit(0)
case *writeExampleConfig:
m.writeExampleConfig()
os.Exit(0)
}
m.LoadConfig()
if *generateRegistration {
m.GenerateRegistration()
os.Exit(0)
}
}
func (m *Main) writeExampleConfig() {
if *configPath != "-" {
if _, err := os.Stat(*configPath); !errors.Is(err, os.ErrNotExist) {
_, _ = fmt.Fprintln(os.Stderr, *configPath, "already exists, please remove it if you want to generate a new example")
os.Exit(1)
}
}
networkExample, _, _ := m.Connector.GetConfig()
example := makeExampleConfig(m.Connector.GetName(), networkExample)
if *configPath == "-" {
fmt.Print(example)
return
}
exerrors.PanicIfNotNil(os.WriteFile(*configPath, []byte(example), 0o600))
fmt.Println("Wrote example config to", *configPath)
}
func (m *Main) GenerateRegistration() {
if !m.SaveConfig {
_, _ = fmt.Fprintln(os.Stderr, "--no-update is not compatible with --generate-registration")
os.Exit(5)
}
if m.Config.Homeserver.Domain == "example.com" {
_, _ = fmt.Fprintln(os.Stderr, "Homeserver domain is not set")
os.Exit(20)
}
registration := m.Config.GenerateRegistration()
if err := registration.Save(m.RegistrationPath); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to save registration:", err)
os.Exit(21)
}
if err := m.saveConfig(); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to save config:", err)
os.Exit(22)
}
fmt.Println("Registration generated. See https://docs.mau.fi/bridges/general/registering-appservices.html for instructions on installing the registration.")
}
func (m *Main) LoadConfig() {
configData, err := os.ReadFile(m.ConfigPath)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to read config:", err)
os.Exit(10)
}
publicConfig, err := arrconfig.Load(configData)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse config:", err)
os.Exit(10)
}
cfg := publicConfig.Compile()
if err = m.loadRegistrationTokens(&cfg); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse registration:", err)
os.Exit(10)
}
_, networkData, _ := m.Connector.GetConfig()
if networkData != nil {
if err = cfg.Network.Decode(networkData); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse network config:", err)
os.Exit(10)
}
}
cfg.Bridge.Backfill = cfg.Backfill
if err = updateConfigFromEnv(&cfg, networkData, cfg.EnvConfigPrefix); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse environment variables:", err)
os.Exit(10)
}
m.PublicConfig = publicConfig
m.Config = &cfg
}
func (m *Main) loadRegistrationTokens(cfg *bridgeconfig.Config) error {
if m.RegistrationPath == "" {
return nil
}
data, err := os.ReadFile(m.RegistrationPath)
if errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return err
}
var tokens struct {
AppToken string `yaml:"as_token"`
ServerToken string `yaml:"hs_token"`
}
if err = yaml.Unmarshal(data, &tokens); err != nil {
return err
}
if tokens.AppToken != "" {
cfg.AppService.ASToken = tokens.AppToken
}
if tokens.ServerToken != "" {
cfg.AppService.HSToken = tokens.ServerToken
}
return nil
}
func (m *Main) Init() {
var err error
m.Log, err = m.Config.Logging.Compile()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to initialize logger:", err)
os.Exit(12)
}
exzerolog.SetupDefaults(m.Log)
if err = m.validateConfig(); err != nil {
m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Configuration error")
m.Log.Info().Msg("See https://docs.mau.fi/faq/field-unconfigured for more info")
os.Exit(11)
}
m.Log.Info().
Str("name", m.Name).
Str("version", m.ver.FormattedVersion).
Time("built_at", m.ver.BuildTime).
Str("go_version", runtime.Version()).
Msg("Initializing bridge")
m.initDB()
m.Matrix = matrix.NewConnector(m.Config)
m.Matrix.OnWebsocketReplaced = func() {
m.TriggerStop(0)
}
m.Matrix.IgnoreUnsupportedServer = *ignoreUnsupportedServer
m.Bridge = bridgev2.NewBridge("", m.DB, *m.Log, &m.Config.Bridge, m.Matrix, m.Connector, commands.NewProcessor)
m.Bridge.Commands = matrixcmd.NewProcessor(m.Bridge, m.Config.ManagementRoomTexts)
if m.Matrix.EventProcessor != nil {
if m.Config.AppService.AsyncTransactions {
m.Matrix.EventProcessor.ExecMode = appservice.AsyncLoop
} else {
m.Matrix.EventProcessor.ExecMode = appservice.Sync
}
m.Matrix.EventProcessor.PrependHandler(event.StateMember, func(ctx context.Context, evt *event.Event) {
onboarding.HandleBotInvite(ctx, m.Bridge, m.Config.ManagementRoomTexts, evt)
})
}
m.Matrix.AS.DoublePuppetValue = m.Name
if mounter, ok := m.Connector.(routeMounter); ok {
if err = mounter.MountRoutes(m.Matrix.AS.Router); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to mount HTTP routes:", err)
os.Exit(13)
}
}
if m.PostInit != nil {
m.PostInit()
}
}
func (m *Main) Start() {
ctx := m.Log.WithContext(context.Background())
if err := m.Bridge.Start(ctx); err != nil {
m.Log.Fatal().Err(err).Msg("Failed to start bridge")
}
if m.PostStart != nil {
m.PostStart()
}
}
func (m *Main) Stop() {
m.Bridge.StopWithTimeout(5 * time.Second)
}
func (m *Main) WaitForInterrupt() int {
interrupts := make(chan os.Signal, 1)
signal.Notify(interrupts, os.Interrupt, syscall.SIGTERM)
select {
case <-interrupts:
m.Log.Info().Msg("Interrupt signal received from OS")
return 0
case exitCode := <-m.manualStop:
m.Log.Info().Msg("Internal stop signal received")
return exitCode
}
}
func (m *Main) TriggerStop(exitCode int) {
select {
case m.manualStop <- exitCode:
default:
}
}
func (m *Main) InitVersion(tag, commit, rawBuildTime string) {
m.ver = progver.ProgramVersion{
Name: m.Name,
URL: m.URL,
BaseVersion: m.Version,
}.Init(tag, commit, rawBuildTime)
mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", m.Name, m.ver.FormattedVersion, mautrix.DefaultUserAgent)
m.Version = m.ver.FormattedVersion
}
func (m *Main) validateConfig() error {
switch {
case m.Config.Homeserver.Address == "http://example.localhost:8008":
return errors.New("homeserver.address not configured")
case m.Config.Homeserver.Domain == "example.com":
return errors.New("homeserver.domain not configured")
case !bridgeconfig.AllowedHomeserverSoftware[m.Config.Homeserver.Software]:
return errors.New("invalid value for homeserver.software (use `standard` if you don't know what the field is for)")
case m.Config.AppService.ASToken == "This value is generated when generating the registration":
return errors.New("appservice.as_token not configured. Did you forget to generate the registration?")
case m.Config.AppService.HSToken == "This value is generated when generating the registration":
return errors.New("appservice.hs_token not configured. Did you forget to generate the registration?")
case m.Config.Database.URI == "postgres://user:password@host/database?sslmode=disable":
return errors.New("database.uri not configured")
case !m.Config.Bridge.Permissions.IsConfigured():
return errors.New("bridge.permissions not configured")
case !strings.Contains(m.Config.AppService.FormatUsername("1234567890"), "1234567890"):
return errors.New("username template is missing user ID placeholder")
default:
if validator, ok := m.Connector.(bridgev2.ConfigValidatingNetwork); ok {
return validator.ValidateConfig()
}
return nil
}
}
func (m *Main) initDB() {
if m.Config.Database.Type == "sqlite3" {
m.Log.WithLevel(zerolog.FatalLevel).Msg("Invalid database type sqlite3. Use sqlite3-fk-wal instead.")
os.Exit(14)
}
if (m.Config.Database.Type == "sqlite3-fk-wal" || m.Config.Database.Type == "litestream") &&
m.Config.Database.MaxOpenConns != 1 &&
!strings.Contains(m.Config.Database.URI, "_txlock=immediate") {
var fixedURI string
switch {
case !strings.HasPrefix(m.Config.Database.URI, "file:"):
fixedURI = fmt.Sprintf("file:%s?_txlock=immediate", m.Config.Database.URI)
case !strings.ContainsRune(m.Config.Database.URI, '?'):
fixedURI = fmt.Sprintf("%s?_txlock=immediate", m.Config.Database.URI)
default:
fixedURI = fmt.Sprintf("%s&_txlock=immediate", m.Config.Database.URI)
}
m.Log.Warn().Str("fixed_uri_example", fixedURI).Msg("Using SQLite without _txlock=immediate is not recommended")
}
var err error
m.DB, err = dbutil.NewFromConfig("megabridge/"+m.Name, m.Config.Database, dbutil.ZeroLogger(m.Log.With().Str("db_section", "main").Logger()))
if err != nil {
m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to initialize database connection")
os.Exit(14)
}
m.DB.IgnoreUnsupportedDatabase = *ignoreUnsupportedDatabase
m.DB.IgnoreForeignTables = *ignoreForeignTables
}
func (m *Main) saveConfig() error {
publicConfig := *m.PublicConfig
publicConfig.AppService.ASToken = m.Config.AppService.ASToken
publicConfig.AppService.HSToken = m.Config.AppService.HSToken
configData, err := yaml.Marshal(&publicConfig)
if err != nil {
return err
}
return os.WriteFile(m.ConfigPath, configData, 0o600)
}

View file

@ -0,0 +1,30 @@
package runtime
import (
"os"
"path/filepath"
"testing"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
)
func TestLoadRegistrationTokens(t *testing.T) {
tempDir := t.TempDir()
registrationPath := filepath.Join(tempDir, "registration.yaml")
if err := os.WriteFile(registrationPath, []byte("as_token: app-token\nhs_token: hs-token\n"), 0o600); err != nil {
t.Fatalf("failed to write registration file: %v", err)
}
cfg := &bridgeconfig.Config{}
main := &Main{RegistrationPath: registrationPath}
if err := main.loadRegistrationTokens(cfg); err != nil {
t.Fatalf("loadRegistrationTokens returned error: %v", err)
}
if cfg.AppService.ASToken != "app-token" {
t.Fatalf("expected as token to be loaded, got %q", cfg.AppService.ASToken)
}
if cfg.AppService.HSToken != "hs-token" {
t.Fatalf("expected hs token to be loaded, got %q", cfg.AppService.HSToken)
}
}