From fe627f3aab7143dd36e2e39caf68f2e917e875c4 Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Thu, 16 Apr 2026 09:06:57 +0200 Subject: [PATCH 1/2] 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 --- .../nixos/temp/services/arrtrix/default.nix | 31 +- packages/arrtrix/cmd/arrtrix/main.go | 5 +- packages/arrtrix/pkg/config/config.go | 61 +++ packages/arrtrix/pkg/config/config_test.go | 122 +++++ packages/arrtrix/pkg/connector/config.go | 37 +- packages/arrtrix/pkg/connector/config_test.go | 23 + packages/arrtrix/pkg/connector/connector.go | 2 + .../arrtrix/pkg/connector/example-config.yaml | 15 +- packages/arrtrix/pkg/matrixcmd/help.go | 60 +++ packages/arrtrix/pkg/matrixcmd/help_test.go | 42 ++ packages/arrtrix/pkg/matrixcmd/processor.go | 204 +++++++++ packages/arrtrix/pkg/onboarding/welcome.go | 137 ++++++ .../arrtrix/pkg/onboarding/welcome_test.go | 56 +++ packages/arrtrix/pkg/runtime/envconfig.go | 206 +++++++++ packages/arrtrix/pkg/runtime/example.go | 69 +++ packages/arrtrix/pkg/runtime/main.go | 418 ++++++++++++++++++ packages/arrtrix/pkg/runtime/main_test.go | 30 ++ packages/arrtrix/pkg/webhook/radarr.go | 241 ++++++++++ packages/arrtrix/pkg/webhook/radarr_test.go | 131 ++++++ 19 files changed, 1855 insertions(+), 35 deletions(-) create mode 100644 packages/arrtrix/pkg/config/config.go create mode 100644 packages/arrtrix/pkg/config/config_test.go create mode 100644 packages/arrtrix/pkg/connector/config_test.go create mode 100644 packages/arrtrix/pkg/matrixcmd/help.go create mode 100644 packages/arrtrix/pkg/matrixcmd/help_test.go create mode 100644 packages/arrtrix/pkg/matrixcmd/processor.go create mode 100644 packages/arrtrix/pkg/onboarding/welcome.go create mode 100644 packages/arrtrix/pkg/onboarding/welcome_test.go create mode 100644 packages/arrtrix/pkg/runtime/envconfig.go create mode 100644 packages/arrtrix/pkg/runtime/example.go create mode 100644 packages/arrtrix/pkg/runtime/main.go create mode 100644 packages/arrtrix/pkg/runtime/main_test.go create mode 100644 packages/arrtrix/pkg/webhook/radarr.go create mode 100644 packages/arrtrix/pkg/webhook/radarr_test.go diff --git a/modules/nixos/temp/services/arrtrix/default.nix b/modules/nixos/temp/services/arrtrix/default.nix index dfd7b32..67ff0b9 100644 --- a/modules/nixos/temp/services/arrtrix/default.nix +++ b/modules/nixos/temp/services/arrtrix/default.nix @@ -15,13 +15,18 @@ settingsFormat = pkgs.formats.json {}; defaultConfig = { + network.webhooks.radarr = { + enabled = false; + path = "/_arrtrix/webhooks/radarr"; + secret = ""; + }; bridge = { command_prefix = "!arr"; relay.enabled = true; permissions."*" = "relay"; }; database = { - type = "sqlite3"; + type = "sqlite3-fk-wal"; uri = "file:${dataDir}/arrtrix.db?_txlock=immediate"; }; homeserver = { @@ -40,17 +45,6 @@ hs_token = ""; username_template = "arrtrix_{{.}}"; }; - double_puppet = { - servers = {}; - secrets = {}; - }; - # By default, the following keys/secrets are set to `generate`. This would break when the service - # is restarted, since the previously generated configuration will be overwritten everytime. - # If encryption is enabled, it's recommended to set those keys via `environmentFile`. - encryption.pickle_key = ""; - provisioning.shared_secret = ""; - public_media.signing_key = ""; - direct_media.server_key = ""; logging = { min_level = "info"; writers = lib.singleton { @@ -145,19 +139,6 @@ in { ${lib.getExe cfg.package} --generate-registration --config='${settingsFile}' --registration='${registrationFile}' fi chmod 640 ${registrationFile} - - # 1. Overwrite registration tokens in config - # 2. If environment variable MAUTRIX_SIGNAL_BRIDGE_LOGIN_SHARED_SECRET - # is set, set it as the login shared secret value for the configured - # homeserver domain. - umask 0177 - ${lib.getExe pkgs.yq} -s '.[0].appservice.as_token = .[1].as_token - | .[0].appservice.hs_token = .[1].hs_token - | .[0] - | if env.MAUTRIX_SIGNAL_BRIDGE_LOGIN_SHARED_SECRET then .double_puppet.secrets.[.homeserver.domain] = env.MAUTRIX_SIGNAL_BRIDGE_LOGIN_SHARED_SECRET else . end' \ - '${settingsFile}' '${registrationFile}' > '${settingsFile}.tmp' - mv '${settingsFile}.tmp' '${settingsFile}' - umask $old_umask ''; serviceConfig = { diff --git a/packages/arrtrix/cmd/arrtrix/main.go b/packages/arrtrix/cmd/arrtrix/main.go index 7958a39..3fa476f 100644 --- a/packages/arrtrix/cmd/arrtrix/main.go +++ b/packages/arrtrix/cmd/arrtrix/main.go @@ -1,9 +1,8 @@ package main import ( - "maunium.net/go/mautrix/bridgev2/matrix/mxmain" - "sneeuwvlok/packages/arrtrix/pkg/connector" + "sneeuwvlok/packages/arrtrix/pkg/runtime" ) var ( @@ -12,7 +11,7 @@ var ( BuildTime = "unknown" ) -var m = mxmain.BridgeMain{ +var m = runtime.Main{ Name: "arrtrix", URL: "https://github.com/chris-kruining/sneeuwvlok", Description: "An Arr-focused Matrix appservice bridge.", diff --git a/packages/arrtrix/pkg/config/config.go b/packages/arrtrix/pkg/config/config.go new file mode 100644 index 0000000..c3b11b8 --- /dev/null +++ b/packages/arrtrix/pkg/config/config.go @@ -0,0 +1,61 @@ +package config + +import ( + "go.mau.fi/util/dbutil" + "go.mau.fi/zeroconfig" + "gopkg.in/yaml.v3" + + "maunium.net/go/mautrix/bridgev2/bridgeconfig" +) + +type Config struct { + Network yaml.Node `yaml:"network"` + + Bridge bridgeconfig.BridgeConfig `yaml:"bridge"` + Database dbutil.Config `yaml:"database"` + Homeserver bridgeconfig.HomeserverConfig `yaml:"homeserver"` + AppService bridgeconfig.AppserviceConfig `yaml:"appservice"` + Logging zeroconfig.Config `yaml:"logging"` + + EnvConfigPrefix string `yaml:"env_config_prefix"` + ManagementTexts bridgeconfig.ManagementRoomTexts `yaml:"management_room_texts"` +} + +func Load(data []byte) (*Config, error) { + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + cfg.applyDefaults() + return &cfg, nil +} + +func (c *Config) applyDefaults() { + if c.Homeserver.Software == "" { + c.Homeserver.Software = bridgeconfig.SoftwareStandard + } +} + +func (c *Config) Compile() bridgeconfig.Config { + return bridgeconfig.Config{ + Network: c.Network, + Bridge: c.Bridge, + Database: c.Database, + Homeserver: c.Homeserver, + AppService: c.AppService, + Logging: c.Logging, + EnvConfigPrefix: c.EnvConfigPrefix, + ManagementRoomTexts: c.ManagementTexts, + Matrix: bridgeconfig.MatrixConfig{ + MessageStatusEvents: false, + DeliveryReceipts: false, + MessageErrorNotices: true, + SyncDirectChatList: false, + FederateRooms: true, + }, + DoublePuppet: bridgeconfig.DoublePuppetConfig{ + Servers: map[string]string{}, + Secrets: map[string]string{}, + }, + } +} diff --git a/packages/arrtrix/pkg/config/config_test.go b/packages/arrtrix/pkg/config/config_test.go new file mode 100644 index 0000000..dc08292 --- /dev/null +++ b/packages/arrtrix/pkg/config/config_test.go @@ -0,0 +1,122 @@ +package config + +import ( + "testing" + + "maunium.net/go/mautrix/bridgev2/bridgeconfig" +) + +func TestLoadDefaultsHomeserverSoftware(t *testing.T) { + cfg, err := Load([]byte(` +bridge: + command_prefix: "!arr" +homeserver: + address: http://127.0.0.1:8008 + domain: test.local +appservice: + id: arrtrix + bot: + username: arrtrixbot + displayname: Arrtrix Bot + username_template: arrtrix_{{.}} +database: + type: sqlite3-fk-wal + uri: file:arrtrix.db?_txlock=immediate +logging: + min_level: info + writers: + - type: stdout + format: pretty-colored +`)) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if cfg.Homeserver.Software != bridgeconfig.SoftwareStandard { + t.Fatalf("expected homeserver software default %q, got %q", bridgeconfig.SoftwareStandard, cfg.Homeserver.Software) + } +} + +func TestCompileSetsInternalDefaultsForHiddenSections(t *testing.T) { + cfg, err := Load([]byte(` +bridge: + command_prefix: "!arr" + permissions: + "*": relay +homeserver: + address: http://127.0.0.1:8008 + domain: test.local +appservice: + id: arrtrix + bot: + username: arrtrixbot + displayname: Arrtrix Bot + username_template: arrtrix_{{.}} +database: + type: sqlite3-fk-wal + uri: file:arrtrix.db?_txlock=immediate +logging: + min_level: info + writers: + - type: stdout + format: pretty-colored +`)) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + runtimeCfg := cfg.Compile() + if !runtimeCfg.Matrix.MessageErrorNotices { + t.Fatalf("expected message error notices to stay enabled") + } + if !runtimeCfg.Matrix.FederateRooms { + t.Fatalf("expected federated rooms to stay enabled") + } + if runtimeCfg.DoublePuppet.Servers == nil || runtimeCfg.DoublePuppet.Secrets == nil { + t.Fatalf("expected hidden double puppet maps to be initialized") + } +} + +func TestLoadIgnoresLegacyHiddenSections(t *testing.T) { + cfg, err := Load([]byte(` +bridge: + command_prefix: "!arr" +homeserver: + address: http://127.0.0.1:8008 + domain: test.local +appservice: + id: arrtrix + bot: + username: arrtrixbot + displayname: Arrtrix Bot + username_template: arrtrix_{{.}} +database: + type: sqlite3-fk-wal + uri: file:arrtrix.db?_txlock=immediate +logging: + min_level: info + writers: + - type: stdout + format: pretty-colored +matrix: + federate_rooms: false +provisioning: + shared_secret: ignored +double_puppet: + secrets: + test.local: secret +encryption: + allow: true +`)) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + runtimeCfg := cfg.Compile() + if !runtimeCfg.Matrix.FederateRooms { + t.Fatalf("expected runtime defaults to win for hidden legacy sections") + } + if len(runtimeCfg.DoublePuppet.Secrets) != 0 { + t.Fatalf("expected hidden double puppet secrets to stay internal-only") + } +} diff --git a/packages/arrtrix/pkg/connector/config.go b/packages/arrtrix/pkg/connector/config.go index 98a0916..3702cee 100644 --- a/packages/arrtrix/pkg/connector/config.go +++ b/packages/arrtrix/pkg/connector/config.go @@ -2,17 +2,52 @@ package connector import ( _ "embed" + "fmt" + "net/http" up "go.mau.fi/util/configupgrade" + "maunium.net/go/mautrix/bridgev2" + + "sneeuwvlok/packages/arrtrix/pkg/webhook" ) //go:embed example-config.yaml var ExampleConfig string -type Config struct{} +type Config struct { + Webhooks WebhooksConfig `yaml:"webhooks"` +} + +type WebhooksConfig struct { + Radarr webhook.RadarrConfig `yaml:"radarr"` +} + +func (c *Config) applyDefaults() { + c.Webhooks.Radarr.ApplyDefaults() +} + +func (c *Config) Validate() error { + return c.Webhooks.Radarr.Validate() +} func upgradeConfig(helper up.Helper) {} func (s *ArrtrixConnector) GetConfig() (string, any, up.Upgrader) { + s.Config.applyDefaults() return ExampleConfig, &s.Config, up.SimpleUpgrader(upgradeConfig) } + +func (s *ArrtrixConnector) ValidateConfig() error { + s.Config.applyDefaults() + return s.Config.Validate() +} + +func (s *ArrtrixConnector) MountRoutes(router *http.ServeMux) error { + s.Config.applyDefaults() + if s.Bridge == nil { + return fmt.Errorf("bridge is not initialized") + } + return webhook.MountRadarr(router, s.Bridge, s.Config.Webhooks.Radarr) +} + +var _ bridgev2.ConfigValidatingNetwork = (*ArrtrixConnector)(nil) diff --git a/packages/arrtrix/pkg/connector/config_test.go b/packages/arrtrix/pkg/connector/config_test.go new file mode 100644 index 0000000..5199308 --- /dev/null +++ b/packages/arrtrix/pkg/connector/config_test.go @@ -0,0 +1,23 @@ +package connector + +import "testing" + +func TestConfigDefaultsApplyRadarrWebhookPath(t *testing.T) { + var cfg Config + + cfg.applyDefaults() + + if cfg.Webhooks.Radarr.Path == "" { + t.Fatal("expected radarr webhook path default to be set") + } +} + +func TestConfigValidateRejectsEnabledWebhookWithoutSecret(t *testing.T) { + cfg := Config{} + cfg.Webhooks.Radarr.Enabled = true + cfg.applyDefaults() + + if err := cfg.Validate(); err == nil { + t.Fatal("expected missing secret to fail validation") + } +} diff --git a/packages/arrtrix/pkg/connector/connector.go b/packages/arrtrix/pkg/connector/connector.go index e90ed46..121e94c 100644 --- a/packages/arrtrix/pkg/connector/connector.go +++ b/packages/arrtrix/pkg/connector/connector.go @@ -3,6 +3,7 @@ package connector import ( "context" "fmt" + "net/http" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" @@ -17,6 +18,7 @@ type ArrtrixConnector struct { } var _ bridgev2.NetworkConnector = (*ArrtrixConnector)(nil) +var _ interface{ MountRoutes(*http.ServeMux) error } = (*ArrtrixConnector)(nil) func (s *ArrtrixConnector) GetName() bridgev2.BridgeName { return bridgev2.BridgeName{ diff --git a/packages/arrtrix/pkg/connector/example-config.yaml b/packages/arrtrix/pkg/connector/example-config.yaml index 63a205e..5fa52c6 100644 --- a/packages/arrtrix/pkg/connector/example-config.yaml +++ b/packages/arrtrix/pkg/connector/example-config.yaml @@ -1,7 +1,10 @@ -# No network-specific config is required yet. +# Arrtrix-specific runtime options. # -# Future Arr-specific runtime options, such as webhook handling, can be added -# here without changing the shared mautrix bridge CLI/runtime shape. -# -# The CLI-provided config file is still fully used by the bridge runtime for -# all shared sections like bridge, database, homeserver, and appservice. +webhooks: + radarr: + enabled: false + path: /_arrtrix/webhooks/radarr + secret: "" + # The first implementation delivers notifications to the only configured + # management room. If more than one management room exists, the webhook is + # rejected until routing is configured more explicitly. diff --git a/packages/arrtrix/pkg/matrixcmd/help.go b/packages/arrtrix/pkg/matrixcmd/help.go new file mode 100644 index 0000000..7da0d84 --- /dev/null +++ b/packages/arrtrix/pkg/matrixcmd/help.go @@ -0,0 +1,60 @@ +package matrixcmd + +import ( + "fmt" + "sort" + "strings" +) + +func NewHelpHandler(proc *Processor) Handler { + return NewHandler(Meta{ + Name: "help", + Description: "Show this help message.", + }, func(ctx *Context) { + ctx.Reply(formatHelp(proc, ctx)) + }) +} + +func formatHelp(proc *Processor, ctx *Context) string { + var builder strings.Builder + + switch { + case ctx.RoomID == ctx.User.ManagementRoom: + builder.WriteString(fmt.Sprintf("This is your management room: prefixing commands with `%s` is not required.\n", ctx.Bridge.Config.CommandPrefix)) + case ctx.Portal != nil: + builder.WriteString(fmt.Sprintf("**This is a portal room**: you must always prefix commands with `%s`. Management commands will not be bridged.\n", ctx.Bridge.Config.CommandPrefix)) + default: + builder.WriteString(fmt.Sprintf("This is not your management room: prefixing commands with `%s` is required.\n", ctx.Bridge.Config.CommandPrefix)) + } + + builder.WriteString("Parameters in [square brackets] are optional, while parameters in are required.\n\n") + builder.WriteString("#### General\n") + + handlers := proc.Handlers() + sort.SliceStable(handlers, func(i, j int) bool { + return handlers[i].Meta().Name < handlers[j].Meta().Name + }) + for _, handler := range handlers { + meta := handler.Meta() + builder.WriteString("**") + builder.WriteString(meta.Name) + builder.WriteString("**") + if meta.Usage != "" { + builder.WriteByte(' ') + builder.WriteString(meta.Usage) + } + if meta.Description != "" { + builder.WriteString(" - ") + builder.WriteString(meta.Description) + } + builder.WriteByte('\n') + } + + if extra := strings.TrimSpace(ctx.Processor.texts.AdditionalHelp); extra != "" { + builder.WriteByte('\n') + builder.WriteString(extra) + builder.WriteByte('\n') + } + + return builder.String() +} diff --git a/packages/arrtrix/pkg/matrixcmd/help_test.go b/packages/arrtrix/pkg/matrixcmd/help_test.go new file mode 100644 index 0000000..b5b325b --- /dev/null +++ b/packages/arrtrix/pkg/matrixcmd/help_test.go @@ -0,0 +1,42 @@ +package matrixcmd + +import ( + "strings" + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/id" +) + +func TestFormatHelpManagementRoom(t *testing.T) { + roomID := id.RoomID("!arrtrix:test") + proc := &Processor{ + texts: bridgeconfig.ManagementRoomTexts{AdditionalHelp: "Extra help text."}, + command: make(map[string]Handler), + alias: make(map[string]string), + } + proc.Add(NewHelpHandler(proc)) + + out := formatHelp(proc, &Context{ + Bridge: &bridgev2.Bridge{ + Config: &bridgeconfig.BridgeConfig{ + CommandPrefix: "!arr", + }, + }, + RoomID: roomID, + User: &bridgev2.User{User: &database.User{ManagementRoom: roomID}}, + Processor: proc, + }) + + for _, fragment := range []string{ + "prefixing commands with `!arr` is not required", + "**help** - Show this help message.", + "Extra help text.", + } { + if !strings.Contains(out, fragment) { + t.Fatalf("expected help output to contain %q, got:\n%s", fragment, out) + } + } +} diff --git a/packages/arrtrix/pkg/matrixcmd/processor.go b/packages/arrtrix/pkg/matrixcmd/processor.go new file mode 100644 index 0000000..1dabfd6 --- /dev/null +++ b/packages/arrtrix/pkg/matrixcmd/processor.go @@ -0,0 +1,204 @@ +package matrixcmd + +import ( + "context" + "fmt" + "runtime/debug" + "sort" + "strings" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" +) + +type Handler interface { + Meta() Meta + Run(*Context) +} + +type Meta struct { + Name string + Description string + Usage string + Aliases []string +} + +type HandlerFunc struct { + meta Meta + run func(*Context) +} + +func NewHandler(meta Meta, run func(*Context)) Handler { + return HandlerFunc{meta: meta, run: run} +} + +func (h HandlerFunc) Meta() Meta { + return h.meta +} + +func (h HandlerFunc) Run(ctx *Context) { + h.run(ctx) +} + +type Processor struct { + bridge *bridgev2.Bridge + bot bridgev2.MatrixAPI + texts bridgeconfig.ManagementRoomTexts + command map[string]Handler + alias map[string]string + order []string +} + +type Context struct { + Bridge *bridgev2.Bridge + Bot bridgev2.MatrixAPI + RoomID id.RoomID + OrigRoomID id.RoomID + EventID id.EventID + ReplyTo id.EventID + User *bridgev2.User + Portal *bridgev2.Portal + Command string + Args []string + RawArgs string + Ctx context.Context + Log *zerolog.Logger + Processor *Processor +} + +var _ bridgev2.CommandProcessor = (*Processor)(nil) + +func NewProcessor(bridge *bridgev2.Bridge, texts bridgeconfig.ManagementRoomTexts) *Processor { + proc := &Processor{ + bridge: bridge, + bot: bridge.Bot, + texts: texts, + command: make(map[string]Handler), + alias: make(map[string]string), + } + proc.Add(NewHelpHandler(proc)) + return proc +} + +func (p *Processor) Add(handler Handler) { + meta := handler.Meta() + p.command[meta.Name] = handler + p.order = append(p.order, meta.Name) + for _, alias := range meta.Aliases { + p.alias[alias] = meta.Name + } +} + +func (p *Processor) Handlers() []Handler { + names := append([]string(nil), p.order...) + sort.Strings(names) + + handlers := make([]Handler, 0, len(names)) + for _, name := range names { + handler, ok := p.command[name] + if ok { + handlers = append(handlers, handler) + } + } + return handlers +} + +func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user *bridgev2.User, message string, replyTo id.EventID) { + ms := &bridgev2.MessageStatus{ + Step: status.MsgStepCommand, + Status: event.MessageStatusSuccess, + } + + logCopy := zerolog.Ctx(ctx).With().Logger() + log := &logCopy + + defer func() { + statusInfo := &bridgev2.MessageStatusEventInfo{ + RoomID: roomID, + SourceEventID: eventID, + EventType: event.EventMessage, + Sender: user.MXID, + } + + if recovered := recover(); recovered != nil { + logEvt := log.Error().Bytes(zerolog.ErrorStackFieldName, debug.Stack()) + if err, ok := recovered.(error); ok { + logEvt = logEvt.Err(err) + ms.InternalError = err + } else { + logEvt = logEvt.Any(zerolog.ErrorFieldName, recovered) + ms.InternalError = fmt.Errorf("%v", recovered) + } + logEvt.Msg("Panic in arrtrix Matrix command handler") + ms.Status = event.MessageStatusFail + ms.IsCertain = true + ms.ErrorAsMessage = true + } + + p.bridge.Matrix.SendMessageStatus(ctx, ms, statusInfo) + }() + + args := strings.Fields(message) + if len(args) == 0 { + args = []string{"unknown-command"} + } + + commandName := strings.ToLower(args[0]) + if actual, ok := p.alias[commandName]; ok { + commandName = actual + } + + portal, err := p.bridge.GetPortalByMXID(ctx, roomID) + if err != nil { + log.Err(err).Msg("Failed to get portal") + } + + commandCtx := &Context{ + Bridge: p.bridge, + Bot: p.bot, + RoomID: roomID, + OrigRoomID: roomID, + EventID: eventID, + ReplyTo: replyTo, + User: user, + Portal: portal, + Command: commandName, + Args: args[1:], + RawArgs: strings.TrimSpace(strings.TrimPrefix(message, args[0])), + Ctx: ctx, + Log: log, + Processor: p, + } + + handler, ok := p.command[commandName] + if !ok { + log.Debug().Str("mx_command", commandName).Msg("Received unknown Matrix room command") + commandCtx.Reply("Unknown command, use the `help` command for help.") + return + } + + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("mx_command", commandName) + }) + log.Debug().Msg("Received Matrix room command") + handler.Run(commandCtx) +} + +func (c *Context) Reply(message string, args ...any) { + message = strings.ReplaceAll(message, "$cmdprefix ", c.Bridge.Config.CommandPrefix+" ") + if len(args) > 0 { + message = fmt.Sprintf(message, args...) + } + + content := format.RenderMarkdown(message, true, false) + content.MsgType = event.MsgNotice + if _, err := c.Bot.SendMessage(c.Ctx, c.OrigRoomID, event.EventMessage, &event.Content{Parsed: &content}, nil); err != nil { + c.Log.Err(err).Msg("Failed to reply to Matrix room command") + } +} diff --git a/packages/arrtrix/pkg/onboarding/welcome.go b/packages/arrtrix/pkg/onboarding/welcome.go new file mode 100644 index 0000000..14860c1 --- /dev/null +++ b/packages/arrtrix/pkg/onboarding/welcome.go @@ -0,0 +1,137 @@ +package onboarding + +import ( + "context" + "fmt" + "strings" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" +) + +const handledInviteEventType = "com.arrtrix.handled_invite" + +func HandleBotInvite(ctx context.Context, bridge *bridgev2.Bridge, texts bridgeconfig.ManagementRoomTexts, evt *event.Event) { + if evt.Type != event.StateMember || + evt.GetStateKey() != bridge.Bot.GetMXID().String() || + evt.Content.AsMember().Membership != event.MembershipInvite { + return + } + + log := zerolog.Ctx(ctx) + sender, err := bridge.GetUserByMXID(ctx, evt.Sender) + if err != nil { + log.Err(err).Msg("Failed to load sender for bot invite") + return + } + if !sender.Permissions.Commands { + return + } + + if err = bridge.Bot.EnsureJoined(ctx, evt.RoomID); err != nil { + log.Err(err).Msg("Failed to accept invite to room") + return + } + + members, err := bridge.Matrix.GetMembers(ctx, evt.RoomID) + if err != nil { + log.Err(err).Msg("Failed to get members of room after accepting invite") + return + } + if len(members) != 2 { + return + } + + assignedManagementRoom := sender.ManagementRoom == "" + if assignedManagementRoom { + sender.ManagementRoom = evt.RoomID + if err = sender.Save(ctx); err != nil { + log.Err(err).Msg("Failed to update user's management room in database") + return + } + } + + message := buildWelcomeMessage(bridge, texts, sender, assignedManagementRoom) + content := format.RenderMarkdown(message, true, false) + if _, err = bridge.Bot.SendMessage(ctx, evt.RoomID, event.EventMessage, &event.Content{Parsed: &content}, nil); err != nil { + log.Err(err).Msg("Failed to send welcome message to room") + return + } + + evt.Type = event.Type{Type: handledInviteEventType} +} + +func buildWelcomeMessage(bridge *bridgev2.Bridge, texts bridgeconfig.ManagementRoomTexts, sender *bridgev2.User, assignedManagementRoom bool) string { + return composeWelcomeMessage( + bridge.Network.GetName().DisplayName, + bridge.Config.CommandPrefix, + bridge.Bot.GetMXID(), + texts, + sender.GetDefaultLogin() != nil, + assignedManagementRoom, + ) +} + +func composeWelcomeMessage( + bridgeName string, + commandPrefix string, + botMXID id.UserID, + texts bridgeconfig.ManagementRoomTexts, + connected bool, + assignedManagementRoom bool, +) string { + replacer := strings.NewReplacer( + "$cmdprefix", commandPrefix, + "$bridge", bridgeName, + "$bot", string(botMXID), + ) + + var parts []string + + base := strings.TrimSpace(texts.Welcome) + if base == "" { + base = fmt.Sprintf("Hello, I'm the %s bot.", bridgeName) + } + parts = append(parts, replacer.Replace(base)) + + if assignedManagementRoom { + parts = append(parts, "This room has been marked as your management room.") + } else { + parts = append(parts, fmt.Sprintf("Use `%s help` to see available commands in this room.", commandPrefix)) + } + + if connected { + connected := strings.TrimSpace(texts.WelcomeConnected) + if connected == "" { + connected = "You're connected. Use `help` to see the commands available right now." + } + parts = append(parts, replacer.Replace(connected)) + } else { + unconnected := strings.TrimSpace(texts.WelcomeUnconnected) + if unconnected == "" { + unconnected = "Use `help` to see the commands available right now." + } + parts = append(parts, replacer.Replace(unconnected)) + } + + if extra := strings.TrimSpace(texts.AdditionalHelp); extra != "" { + parts = append(parts, replacer.Replace(extra)) + } + + return strings.Join(parts, "\n\n") +} + +func IsHandledInviteEvent(evt *event.Event) bool { + return evt.Type.Type == handledInviteEventType +} + +func IsBotInviteFor(roomBot id.UserID, evt *event.Event) bool { + return evt.Type == event.StateMember && + evt.GetStateKey() == roomBot.String() && + evt.Content.AsMember().Membership == event.MembershipInvite +} diff --git a/packages/arrtrix/pkg/onboarding/welcome_test.go b/packages/arrtrix/pkg/onboarding/welcome_test.go new file mode 100644 index 0000000..de6f42a --- /dev/null +++ b/packages/arrtrix/pkg/onboarding/welcome_test.go @@ -0,0 +1,56 @@ +package onboarding + +import ( + "strings" + "testing" + + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/id" +) + +func TestComposeWelcomeMessageDefaults(t *testing.T) { + out := composeWelcomeMessage( + "Arrtrix", + "!arr", + id.UserID("@arrtrixbot:test"), + bridgeconfig.ManagementRoomTexts{}, + false, + true, + ) + + for _, fragment := range []string{ + "Hello, I'm the Arrtrix bot.", + "This room has been marked as your management room.", + "Use `help` to see the commands available right now.", + } { + if !strings.Contains(out, fragment) { + t.Fatalf("expected welcome output to contain %q, got:\n%s", fragment, out) + } + } +} + +func TestComposeWelcomeMessageTemplateValues(t *testing.T) { + out := composeWelcomeMessage( + "Arrtrix", + "!arr", + id.UserID("@arrtrixbot:test"), + bridgeconfig.ManagementRoomTexts{ + Welcome: "Welcome to $bridge.", + WelcomeConnected: "Talk to $bot with $cmdprefix help.", + AdditionalHelp: "Custom footer for $bridge.", + }, + true, + false, + ) + + for _, fragment := range []string{ + "Welcome to Arrtrix.", + "Use `!arr help` to see available commands in this room.", + "Talk to @arrtrixbot:test with !arr help.", + "Custom footer for Arrtrix.", + } { + if !strings.Contains(out, fragment) { + t.Fatalf("expected templated welcome output to contain %q, got:\n%s", fragment, out) + } + } +} diff --git a/packages/arrtrix/pkg/runtime/envconfig.go b/packages/arrtrix/pkg/runtime/envconfig.go new file mode 100644 index 0000000..f8ffd13 --- /dev/null +++ b/packages/arrtrix/pkg/runtime/envconfig.go @@ -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)) + } +} diff --git a/packages/arrtrix/pkg/runtime/example.go b/packages/arrtrix/pkg/runtime/example.go new file mode 100644 index 0000000..1cba7b6 --- /dev/null +++ b/packages/arrtrix/pkg/runtime/example.go @@ -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() +} diff --git a/packages/arrtrix/pkg/runtime/main.go b/packages/arrtrix/pkg/runtime/main.go new file mode 100644 index 0000000..42e1495 --- /dev/null +++ b/packages/arrtrix/pkg/runtime/main.go @@ -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 ] [-r ]", 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) +} diff --git a/packages/arrtrix/pkg/runtime/main_test.go b/packages/arrtrix/pkg/runtime/main_test.go new file mode 100644 index 0000000..f54201b --- /dev/null +++ b/packages/arrtrix/pkg/runtime/main_test.go @@ -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) + } +} diff --git a/packages/arrtrix/pkg/webhook/radarr.go b/packages/arrtrix/pkg/webhook/radarr.go new file mode 100644 index 0000000..6f74342 --- /dev/null +++ b/packages/arrtrix/pkg/webhook/radarr.go @@ -0,0 +1,241 @@ +package webhook + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" +) + +const ( + defaultRadarrWebhookPath = "/_arrtrix/webhooks/radarr" + radarrSecretHeader = "X-Arrtrix-Webhook-Secret" +) + +var ( + ErrNoManagementRoom = errors.New("no management room configured") + ErrAmbiguousManagementRoom = errors.New("multiple management rooms configured") +) + +type RadarrConfig struct { + Enabled bool `yaml:"enabled"` + Path string `yaml:"path"` + Secret string `yaml:"secret"` +} + +type radarrPayload struct { + EventType string `json:"eventType"` + Movie *radarrMovie `json:"movie"` + MovieFile *radarrMovieFile `json:"movieFile"` + IsUpgrade bool `json:"isUpgrade"` +} + +type radarrMovie struct { + Title string `json:"title"` + Year int `json:"year"` + ImdbID string `json:"imdbId"` + TmdbID int `json:"tmdbId"` + Path string `json:"path"` +} + +type radarrMovieFile struct { + Quality string `json:"quality"` + RelativePath string `json:"relativePath"` + SceneName string `json:"sceneName"` + ReleaseGroup string `json:"releaseGroup"` +} + +type roomResolver interface { + ResolveManagementRoom(context.Context) (id.RoomID, error) +} + +type noticeSender interface { + SendNotice(context.Context, id.RoomID, string) error +} + +type RadarrHandler struct { + config RadarrConfig + resolver roomResolver + sender noticeSender +} + +func (c *RadarrConfig) ApplyDefaults() { + if c.Path == "" { + c.Path = defaultRadarrWebhookPath + } +} + +func (c *RadarrConfig) Validate() error { + c.ApplyDefaults() + if !c.Enabled { + return nil + } + if !strings.HasPrefix(c.Path, "/") { + return fmt.Errorf("network.webhooks.radarr.path must start with /") + } + if strings.TrimSpace(c.Secret) == "" { + return fmt.Errorf("network.webhooks.radarr.secret must be set when the webhook is enabled") + } + return nil +} + +func MountRadarr(router *http.ServeMux, bridge *bridgev2.Bridge, cfg RadarrConfig) error { + cfg.ApplyDefaults() + if !cfg.Enabled { + return nil + } + if err := cfg.Validate(); err != nil { + return err + } + + handler := &RadarrHandler{ + config: cfg, + resolver: bridgeRoomResolver{bridge: bridge}, + sender: bridgeNoticeSender{bridge: bridge}, + } + router.Handle(fmt.Sprintf("POST %s", cfg.Path), handler) + return nil +} + +func (h *RadarrHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !authorized(r, h.config.Secret) { + http.Error(w, "invalid webhook secret", http.StatusUnauthorized) + return + } + + var payload radarrPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid webhook payload", http.StatusBadRequest) + return + } + if strings.TrimSpace(payload.EventType) == "" { + http.Error(w, "missing eventType", http.StatusBadRequest) + return + } + + roomID, err := h.resolver.ResolveManagementRoom(r.Context()) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrNoManagementRoom) || errors.Is(err, ErrAmbiguousManagementRoom) { + status = http.StatusConflict + } + http.Error(w, err.Error(), status) + return + } + + if err = h.sender.SendNotice(r.Context(), roomID, renderRadarrNotice(payload)); err != nil { + http.Error(w, "failed to deliver webhook", http.StatusBadGateway) + return + } + + w.WriteHeader(http.StatusAccepted) +} + +type bridgeRoomResolver struct { + bridge *bridgev2.Bridge +} + +func (r bridgeRoomResolver) ResolveManagementRoom(ctx context.Context) (id.RoomID, error) { + rows, err := r.bridge.DB.Query(ctx, `SELECT mxid, management_room FROM "user" WHERE bridge_id=$1 AND management_room IS NOT NULL AND management_room <> ''`, r.bridge.ID) + if err != nil { + return "", fmt.Errorf("failed to query management rooms: %w", err) + } + defer rows.Close() + + var roomID id.RoomID + var owners []id.UserID + for rows.Next() { + var mxid, managementRoom string + if err = rows.Scan(&mxid, &managementRoom); err != nil { + return "", fmt.Errorf("failed to scan management room: %w", err) + } + owners = append(owners, id.UserID(mxid)) + if roomID == "" { + roomID = id.RoomID(managementRoom) + } + } + if err = rows.Err(); err != nil { + return "", fmt.Errorf("failed to iterate management rooms: %w", err) + } + switch len(owners) { + case 0: + return "", ErrNoManagementRoom + case 1: + return roomID, nil + default: + return "", fmt.Errorf("%w: %s", ErrAmbiguousManagementRoom, strings.Join(convertUserIDs(owners), ", ")) + } +} + +type bridgeNoticeSender struct { + bridge *bridgev2.Bridge +} + +func (s bridgeNoticeSender) SendNotice(ctx context.Context, roomID id.RoomID, markdown string) error { + if err := s.bridge.Bot.EnsureJoined(ctx, roomID); err != nil { + return err + } + content := format.RenderMarkdown(markdown, true, false) + _, err := s.bridge.Bot.SendMessage(ctx, roomID, event.EventMessage, &event.Content{Parsed: &content}, nil) + return err +} + +func authorized(r *http.Request, secret string) bool { + if secret == "" { + return true + } + if r.Header.Get(radarrSecretHeader) == secret { + return true + } + if bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "); bearer == secret && bearer != r.Header.Get("Authorization") { + return true + } + return r.URL.Query().Get("secret") == secret +} + +func renderRadarrNotice(payload radarrPayload) string { + title := "Radarr" + if payload.Movie != nil { + title = payload.Movie.Title + if payload.Movie.Year != 0 { + title = fmt.Sprintf("%s (%d)", title, payload.Movie.Year) + } + } + + lines := []string{fmt.Sprintf("**Radarr %s**", payload.EventType)} + if title != "Radarr" { + lines = append(lines, fmt.Sprintf("Movie: %s", title)) + } + if payload.MovieFile != nil && payload.MovieFile.Quality != "" { + lines = append(lines, fmt.Sprintf("Quality: %s", payload.MovieFile.Quality)) + } + if payload.MovieFile != nil && payload.MovieFile.RelativePath != "" { + lines = append(lines, fmt.Sprintf("File: `%s`", payload.MovieFile.RelativePath)) + } + if payload.EventType == "Download" { + lines = append(lines, fmt.Sprintf("Upgrade: %t", payload.IsUpgrade)) + } + if payload.Movie != nil && payload.Movie.ImdbID != "" { + lines = append(lines, fmt.Sprintf("IMDb: `%s`", payload.Movie.ImdbID)) + } + return strings.Join(lines, "\n") +} + +func convertUserIDs(users []id.UserID) []string { + out := make([]string, len(users)) + for i, user := range users { + out[i] = string(user) + } + return out +} + +var _ roomResolver = bridgeRoomResolver{} +var _ noticeSender = bridgeNoticeSender{} +var _ http.Handler = (*RadarrHandler)(nil) diff --git a/packages/arrtrix/pkg/webhook/radarr_test.go b/packages/arrtrix/pkg/webhook/radarr_test.go new file mode 100644 index 0000000..d4fc962 --- /dev/null +++ b/packages/arrtrix/pkg/webhook/radarr_test.go @@ -0,0 +1,131 @@ +package webhook + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "maunium.net/go/mautrix/id" +) + +type stubRoomResolver struct { + roomID id.RoomID + err error +} + +func (s stubRoomResolver) ResolveManagementRoom(context.Context) (id.RoomID, error) { + return s.roomID, s.err +} + +type stubNoticeSender struct { + roomID id.RoomID + message string + err error +} + +func (s *stubNoticeSender) SendNotice(_ context.Context, roomID id.RoomID, message string) error { + s.roomID = roomID + s.message = message + return s.err +} + +func TestRadarrConfigDefaultsAndValidation(t *testing.T) { + cfg := RadarrConfig{Enabled: true, Secret: "secret"} + cfg.ApplyDefaults() + if cfg.Path != defaultRadarrWebhookPath { + t.Fatalf("expected default path %q, got %q", defaultRadarrWebhookPath, cfg.Path) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("expected config to validate, got %v", err) + } +} + +func TestRadarrConfigRequiresSecretWhenEnabled(t *testing.T) { + cfg := RadarrConfig{Enabled: true} + if err := cfg.Validate(); err == nil { + t.Fatal("expected missing secret to fail validation") + } +} + +func TestRadarrHandlerRejectsUnauthorizedRequests(t *testing.T) { + handler := &RadarrHandler{ + config: RadarrConfig{Enabled: true, Secret: "secret"}, + resolver: stubRoomResolver{roomID: "!room:test"}, + sender: &stubNoticeSender{}, + } + + req := httptest.NewRequest(http.MethodPost, defaultRadarrWebhookPath, strings.NewReader(`{"eventType":"Test"}`)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized status, got %d", rec.Code) + } +} + +func TestRadarrHandlerDeliversNotice(t *testing.T) { + sender := &stubNoticeSender{} + handler := &RadarrHandler{ + config: RadarrConfig{Enabled: true, Secret: "secret"}, + resolver: stubRoomResolver{roomID: "!room:test"}, + sender: sender, + } + + req := httptest.NewRequest(http.MethodPost, defaultRadarrWebhookPath+"?secret=secret", strings.NewReader(`{"eventType":"Download","movie":{"title":"Dune","year":2021,"imdbId":"tt1160419"},"movieFile":{"quality":"1080p","relativePath":"Dune (2021)/Dune.mkv"},"isUpgrade":false}`)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("expected accepted status, got %d", rec.Code) + } + if sender.roomID != "!room:test" { + t.Fatalf("expected notice sent to management room, got %q", sender.roomID) + } + if !strings.Contains(sender.message, "**Radarr Download**") || !strings.Contains(sender.message, "Dune (2021)") { + t.Fatalf("unexpected message: %s", sender.message) + } +} + +func TestRadarrHandlerReportsAmbiguousManagementRoom(t *testing.T) { + handler := &RadarrHandler{ + config: RadarrConfig{Enabled: true, Secret: "secret"}, + resolver: stubRoomResolver{err: ErrAmbiguousManagementRoom}, + sender: &stubNoticeSender{}, + } + + req := httptest.NewRequest(http.MethodPost, defaultRadarrWebhookPath, strings.NewReader(`{"eventType":"Test"}`)) + req.Header.Set(radarrSecretHeader, "secret") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("expected conflict status, got %d", rec.Code) + } +} + +func TestRenderRadarrNoticeForTestEvent(t *testing.T) { + msg := renderRadarrNotice(radarrPayload{EventType: "Test"}) + if strings.TrimSpace(msg) != "**Radarr Test**" { + t.Fatalf("unexpected test-event message: %q", msg) + } +} + +func TestRadarrHandlerReturnsBadGatewayOnSendFailure(t *testing.T) { + handler := &RadarrHandler{ + config: RadarrConfig{Enabled: true, Secret: "secret"}, + resolver: stubRoomResolver{roomID: "!room:test"}, + sender: &stubNoticeSender{err: errors.New("send failed")}, + } + + req := httptest.NewRequest(http.MethodPost, defaultRadarrWebhookPath, strings.NewReader(`{"eventType":"Test"}`)) + req.Header.Set(radarrSecretHeader, "secret") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("expected bad gateway status, got %d", rec.Code) + } +} From bbfe6867c8bf9a2452f611e1992d918b705bd2e3 Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Thu, 16 Apr 2026 09:47:00 +0200 Subject: [PATCH 2/2] Refactor arrtrix webhook to use fixed path and remove legacy config - Switch arrtrix webhook to a fixed path: /_arrtrix/webhook - Remove Radarr-specific and secret-based config from arrtrix - Simplify connector and webhook handler logic - Update NixOS module to drop legacy webhook config - Add new tests for generic arrtrix webhook handler --- .editorconfig | 6 + .gitattributes | 5 +- .../services/communication/matrix/default.nix | 13 +- .../nixos/temp/services/arrtrix/default.nix | 5 - packages/arrtrix/pkg/config/config_test.go | 37 +++++ packages/arrtrix/pkg/connector/config.go | 23 +-- packages/arrtrix/pkg/connector/config_test.go | 23 --- .../arrtrix/pkg/connector/example-config.yaml | 12 +- .../arrtrix/pkg/webhook/{radarr.go => arr.go} | 127 +++++------------ packages/arrtrix/pkg/webhook/arr_test.go | 114 +++++++++++++++ packages/arrtrix/pkg/webhook/radarr_test.go | 131 ------------------ 11 files changed, 211 insertions(+), 285 deletions(-) create mode 100644 .editorconfig delete mode 100644 packages/arrtrix/pkg/connector/config_test.go rename packages/arrtrix/pkg/webhook/{radarr.go => arr.go} (53%) create mode 100644 packages/arrtrix/pkg/webhook/arr_test.go delete mode 100644 packages/arrtrix/pkg/webhook/radarr_test.go diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e62b828 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 diff --git a/.gitattributes b/.gitattributes index 780e15a..6313b56 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1 @@ -* text=auto -core.autocrlf=false -core.eol=lf -core.filemode=false +* text=auto eol=lf diff --git a/modules/nixos/services/communication/matrix/default.nix b/modules/nixos/services/communication/matrix/default.nix index c9c11f1..607fa72 100644 --- a/modules/nixos/services/communication/matrix/default.nix +++ b/modules/nixos/services/communication/matrix/default.nix @@ -18,7 +18,7 @@ keyFile = "/var/lib/element-call/key"; mkMautrix = bridge: i: conf: { - ${bridge} = + ${bridge} = mkMerge [ { enable = true; registerToSynapse = true; @@ -43,7 +43,8 @@ }; }; } - // conf; + conf + ]; }; in { options.${namespace}.services.communication.matrix = { @@ -110,7 +111,13 @@ in { (mkMautrix "mautrix-signal" 1 {}) (mkMautrix "mautrix-telegram" 2 {}) (mkMautrix "mautrix-whatsapp" 3 {}) - (mkMautrix "arrtrix" 4 {}) + (mkMautrix "arrtrix" 4 { + settings.network.webhooks.radarr = { + enabled = true; + path = "/_arrtrix/webhooks/radarr"; + secret = ""; + }; + }) { matrix-synapse = { enable = true; diff --git a/modules/nixos/temp/services/arrtrix/default.nix b/modules/nixos/temp/services/arrtrix/default.nix index 67ff0b9..618de39 100644 --- a/modules/nixos/temp/services/arrtrix/default.nix +++ b/modules/nixos/temp/services/arrtrix/default.nix @@ -15,11 +15,6 @@ settingsFormat = pkgs.formats.json {}; defaultConfig = { - network.webhooks.radarr = { - enabled = false; - path = "/_arrtrix/webhooks/radarr"; - secret = ""; - }; bridge = { command_prefix = "!arr"; relay.enabled = true; diff --git a/packages/arrtrix/pkg/config/config_test.go b/packages/arrtrix/pkg/config/config_test.go index dc08292..84b09df 100644 --- a/packages/arrtrix/pkg/config/config_test.go +++ b/packages/arrtrix/pkg/config/config_test.go @@ -120,3 +120,40 @@ encryption: t.Fatalf("expected hidden double puppet secrets to stay internal-only") } } + +func TestLoadIgnoresLegacyWebhookSettings(t *testing.T) { + cfg, err := Load([]byte(` +network: + webhooks: + radarr: + enabled: true + path: /_arrtrix/webhooks/radarr + secret: legacy-secret +bridge: + command_prefix: "!arr" +homeserver: + address: http://127.0.0.1:8008 + domain: test.local +appservice: + id: arrtrix + bot: + username: arrtrixbot + displayname: Arrtrix Bot + username_template: arrtrix_{{.}} +database: + type: sqlite3-fk-wal + uri: file:arrtrix.db?_txlock=immediate +logging: + min_level: info + writers: + - type: stdout + format: pretty-colored +`)) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if cfg == nil { + t.Fatal("expected config to load") + } +} diff --git a/packages/arrtrix/pkg/connector/config.go b/packages/arrtrix/pkg/connector/config.go index 3702cee..2cdec34 100644 --- a/packages/arrtrix/pkg/connector/config.go +++ b/packages/arrtrix/pkg/connector/config.go @@ -14,40 +14,23 @@ import ( //go:embed example-config.yaml var ExampleConfig string -type Config struct { - Webhooks WebhooksConfig `yaml:"webhooks"` -} - -type WebhooksConfig struct { - Radarr webhook.RadarrConfig `yaml:"radarr"` -} - -func (c *Config) applyDefaults() { - c.Webhooks.Radarr.ApplyDefaults() -} - -func (c *Config) Validate() error { - return c.Webhooks.Radarr.Validate() -} +type Config struct{} func upgradeConfig(helper up.Helper) {} func (s *ArrtrixConnector) GetConfig() (string, any, up.Upgrader) { - s.Config.applyDefaults() return ExampleConfig, &s.Config, up.SimpleUpgrader(upgradeConfig) } func (s *ArrtrixConnector) ValidateConfig() error { - s.Config.applyDefaults() - return s.Config.Validate() + return nil } func (s *ArrtrixConnector) MountRoutes(router *http.ServeMux) error { - s.Config.applyDefaults() if s.Bridge == nil { return fmt.Errorf("bridge is not initialized") } - return webhook.MountRadarr(router, s.Bridge, s.Config.Webhooks.Radarr) + return webhook.MountArr(router, s.Bridge) } var _ bridgev2.ConfigValidatingNetwork = (*ArrtrixConnector)(nil) diff --git a/packages/arrtrix/pkg/connector/config_test.go b/packages/arrtrix/pkg/connector/config_test.go deleted file mode 100644 index 5199308..0000000 --- a/packages/arrtrix/pkg/connector/config_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package connector - -import "testing" - -func TestConfigDefaultsApplyRadarrWebhookPath(t *testing.T) { - var cfg Config - - cfg.applyDefaults() - - if cfg.Webhooks.Radarr.Path == "" { - t.Fatal("expected radarr webhook path default to be set") - } -} - -func TestConfigValidateRejectsEnabledWebhookWithoutSecret(t *testing.T) { - cfg := Config{} - cfg.Webhooks.Radarr.Enabled = true - cfg.applyDefaults() - - if err := cfg.Validate(); err == nil { - t.Fatal("expected missing secret to fail validation") - } -} diff --git a/packages/arrtrix/pkg/connector/example-config.yaml b/packages/arrtrix/pkg/connector/example-config.yaml index 5fa52c6..9c11ddf 100644 --- a/packages/arrtrix/pkg/connector/example-config.yaml +++ b/packages/arrtrix/pkg/connector/example-config.yaml @@ -1,10 +1,4 @@ -# Arrtrix-specific runtime options. +# No network-specific config is required yet. # -webhooks: - radarr: - enabled: false - path: /_arrtrix/webhooks/radarr - secret: "" - # The first implementation delivers notifications to the only configured - # management room. If more than one management room exists, the webhook is - # rejected until routing is configured more explicitly. +# Arr-stack webhooks are exposed automatically on the fixed built-in path: +# POST /_arrtrix/webhook diff --git a/packages/arrtrix/pkg/webhook/radarr.go b/packages/arrtrix/pkg/webhook/arr.go similarity index 53% rename from packages/arrtrix/pkg/webhook/radarr.go rename to packages/arrtrix/pkg/webhook/arr.go index 6f74342..42e350c 100644 --- a/packages/arrtrix/pkg/webhook/radarr.go +++ b/packages/arrtrix/pkg/webhook/arr.go @@ -14,30 +14,21 @@ import ( "maunium.net/go/mautrix/id" ) -const ( - defaultRadarrWebhookPath = "/_arrtrix/webhooks/radarr" - radarrSecretHeader = "X-Arrtrix-Webhook-Secret" -) +const ArrWebhookPath = "/_arrtrix/webhook" var ( ErrNoManagementRoom = errors.New("no management room configured") ErrAmbiguousManagementRoom = errors.New("multiple management rooms configured") ) -type RadarrConfig struct { - Enabled bool `yaml:"enabled"` - Path string `yaml:"path"` - Secret string `yaml:"secret"` +type payload struct { + EventType string `json:"eventType"` + Movie *movie `json:"movie"` + MovieFile *movieFile `json:"movieFile"` + IsUpgrade bool `json:"isUpgrade"` } -type radarrPayload struct { - EventType string `json:"eventType"` - Movie *radarrMovie `json:"movie"` - MovieFile *radarrMovieFile `json:"movieFile"` - IsUpgrade bool `json:"isUpgrade"` -} - -type radarrMovie struct { +type movie struct { Title string `json:"title"` Year int `json:"year"` ImdbID string `json:"imdbId"` @@ -45,7 +36,7 @@ type radarrMovie struct { Path string `json:"path"` } -type radarrMovieFile struct { +type movieFile struct { Quality string `json:"quality"` RelativePath string `json:"relativePath"` SceneName string `json:"sceneName"` @@ -60,62 +51,30 @@ type noticeSender interface { SendNotice(context.Context, id.RoomID, string) error } -type RadarrHandler struct { - config RadarrConfig +type ArrHandler struct { resolver roomResolver sender noticeSender } -func (c *RadarrConfig) ApplyDefaults() { - if c.Path == "" { - c.Path = defaultRadarrWebhookPath +func MountArr(router *http.ServeMux, bridge *bridgev2.Bridge) error { + if bridge == nil { + return fmt.Errorf("bridge is not initialized") } -} - -func (c *RadarrConfig) Validate() error { - c.ApplyDefaults() - if !c.Enabled { - return nil - } - if !strings.HasPrefix(c.Path, "/") { - return fmt.Errorf("network.webhooks.radarr.path must start with /") - } - if strings.TrimSpace(c.Secret) == "" { - return fmt.Errorf("network.webhooks.radarr.secret must be set when the webhook is enabled") - } - return nil -} - -func MountRadarr(router *http.ServeMux, bridge *bridgev2.Bridge, cfg RadarrConfig) error { - cfg.ApplyDefaults() - if !cfg.Enabled { - return nil - } - if err := cfg.Validate(); err != nil { - return err - } - - handler := &RadarrHandler{ - config: cfg, + handler := &ArrHandler{ resolver: bridgeRoomResolver{bridge: bridge}, sender: bridgeNoticeSender{bridge: bridge}, } - router.Handle(fmt.Sprintf("POST %s", cfg.Path), handler) + router.Handle(fmt.Sprintf("POST %s", ArrWebhookPath), handler) return nil } -func (h *RadarrHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if !authorized(r, h.config.Secret) { - http.Error(w, "invalid webhook secret", http.StatusUnauthorized) - return - } - - var payload radarrPayload - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { +func (h *ArrHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var body payload + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid webhook payload", http.StatusBadRequest) return } - if strings.TrimSpace(payload.EventType) == "" { + if strings.TrimSpace(body.EventType) == "" { http.Error(w, "missing eventType", http.StatusBadRequest) return } @@ -130,7 +89,7 @@ func (h *RadarrHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - if err = h.sender.SendNotice(r.Context(), roomID, renderRadarrNotice(payload)); err != nil { + if err = h.sender.SendNotice(r.Context(), roomID, renderNotice(body)); err != nil { http.Error(w, "failed to deliver webhook", http.StatusBadGateway) return } @@ -164,6 +123,7 @@ func (r bridgeRoomResolver) ResolveManagementRoom(ctx context.Context) (id.RoomI if err = rows.Err(); err != nil { return "", fmt.Errorf("failed to iterate management rooms: %w", err) } + switch len(owners) { case 0: return "", ErrNoManagementRoom @@ -187,43 +147,30 @@ func (s bridgeNoticeSender) SendNotice(ctx context.Context, roomID id.RoomID, ma return err } -func authorized(r *http.Request, secret string) bool { - if secret == "" { - return true - } - if r.Header.Get(radarrSecretHeader) == secret { - return true - } - if bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "); bearer == secret && bearer != r.Header.Get("Authorization") { - return true - } - return r.URL.Query().Get("secret") == secret -} - -func renderRadarrNotice(payload radarrPayload) string { - title := "Radarr" - if payload.Movie != nil { - title = payload.Movie.Title - if payload.Movie.Year != 0 { - title = fmt.Sprintf("%s (%d)", title, payload.Movie.Year) +func renderNotice(body payload) string { + title := "Arr" + if body.Movie != nil { + title = body.Movie.Title + if body.Movie.Year != 0 { + title = fmt.Sprintf("%s (%d)", title, body.Movie.Year) } } - lines := []string{fmt.Sprintf("**Radarr %s**", payload.EventType)} - if title != "Radarr" { + lines := []string{fmt.Sprintf("**Arr %s**", body.EventType)} + if title != "Arr" { lines = append(lines, fmt.Sprintf("Movie: %s", title)) } - if payload.MovieFile != nil && payload.MovieFile.Quality != "" { - lines = append(lines, fmt.Sprintf("Quality: %s", payload.MovieFile.Quality)) + if body.MovieFile != nil && body.MovieFile.Quality != "" { + lines = append(lines, fmt.Sprintf("Quality: %s", body.MovieFile.Quality)) } - if payload.MovieFile != nil && payload.MovieFile.RelativePath != "" { - lines = append(lines, fmt.Sprintf("File: `%s`", payload.MovieFile.RelativePath)) + if body.MovieFile != nil && body.MovieFile.RelativePath != "" { + lines = append(lines, fmt.Sprintf("File: `%s`", body.MovieFile.RelativePath)) } - if payload.EventType == "Download" { - lines = append(lines, fmt.Sprintf("Upgrade: %t", payload.IsUpgrade)) + if body.EventType == "Download" { + lines = append(lines, fmt.Sprintf("Upgrade: %t", body.IsUpgrade)) } - if payload.Movie != nil && payload.Movie.ImdbID != "" { - lines = append(lines, fmt.Sprintf("IMDb: `%s`", payload.Movie.ImdbID)) + if body.Movie != nil && body.Movie.ImdbID != "" { + lines = append(lines, fmt.Sprintf("IMDb: `%s`", body.Movie.ImdbID)) } return strings.Join(lines, "\n") } @@ -238,4 +185,4 @@ func convertUserIDs(users []id.UserID) []string { var _ roomResolver = bridgeRoomResolver{} var _ noticeSender = bridgeNoticeSender{} -var _ http.Handler = (*RadarrHandler)(nil) +var _ http.Handler = (*ArrHandler)(nil) diff --git a/packages/arrtrix/pkg/webhook/arr_test.go b/packages/arrtrix/pkg/webhook/arr_test.go new file mode 100644 index 0000000..b7ac511 --- /dev/null +++ b/packages/arrtrix/pkg/webhook/arr_test.go @@ -0,0 +1,114 @@ +package webhook + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "maunium.net/go/mautrix/id" +) + +type stubRoomResolver struct { + roomID id.RoomID + err error +} + +func (s stubRoomResolver) ResolveManagementRoom(context.Context) (id.RoomID, error) { + return s.roomID, s.err +} + +type stubNoticeSender struct { + roomID id.RoomID + message string + err error +} + +func (s *stubNoticeSender) SendNotice(_ context.Context, roomID id.RoomID, message string) error { + s.roomID = roomID + s.message = message + return s.err +} + +func TestMountArrRequiresBridge(t *testing.T) { + router := http.NewServeMux() + if err := MountArr(router, nil); err == nil { + t.Fatal("expected nil bridge to fail") + } +} + +func TestArrHandlerDeliversNotice(t *testing.T) { + sender := &stubNoticeSender{} + handler := &ArrHandler{ + resolver: stubRoomResolver{roomID: "!room:test"}, + sender: sender, + } + + req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Download","movie":{"title":"Dune","year":2021,"imdbId":"tt1160419"},"movieFile":{"quality":"1080p","relativePath":"Dune (2021)/Dune.mkv"},"isUpgrade":false}`)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("expected accepted status, got %d", rec.Code) + } + if sender.roomID != "!room:test" { + t.Fatalf("expected notice sent to management room, got %q", sender.roomID) + } + if !strings.Contains(sender.message, "**Arr Download**") || !strings.Contains(sender.message, "Dune (2021)") { + t.Fatalf("unexpected message: %s", sender.message) + } +} + +func TestArrHandlerReportsAmbiguousManagementRoom(t *testing.T) { + handler := &ArrHandler{ + resolver: stubRoomResolver{err: ErrAmbiguousManagementRoom}, + sender: &stubNoticeSender{}, + } + + req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Test"}`)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("expected conflict status, got %d", rec.Code) + } +} + +func TestRenderNoticeForTestEvent(t *testing.T) { + msg := renderNotice(payload{EventType: "Test"}) + if strings.TrimSpace(msg) != "**Arr Test**" { + t.Fatalf("unexpected test-event message: %q", msg) + } +} + +func TestArrHandlerReturnsBadGatewayOnSendFailure(t *testing.T) { + handler := &ArrHandler{ + resolver: stubRoomResolver{roomID: "!room:test"}, + sender: &stubNoticeSender{err: errors.New("send failed")}, + } + + req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Test"}`)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("expected bad gateway status, got %d", rec.Code) + } +} + +func TestArrHandlerRejectsMissingEventType(t *testing.T) { + handler := &ArrHandler{ + resolver: stubRoomResolver{roomID: "!room:test"}, + sender: &stubNoticeSender{}, + } + + req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"movie":{"title":"Dune"}}`)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected bad request status, got %d", rec.Code) + } +} diff --git a/packages/arrtrix/pkg/webhook/radarr_test.go b/packages/arrtrix/pkg/webhook/radarr_test.go deleted file mode 100644 index d4fc962..0000000 --- a/packages/arrtrix/pkg/webhook/radarr_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package webhook - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "maunium.net/go/mautrix/id" -) - -type stubRoomResolver struct { - roomID id.RoomID - err error -} - -func (s stubRoomResolver) ResolveManagementRoom(context.Context) (id.RoomID, error) { - return s.roomID, s.err -} - -type stubNoticeSender struct { - roomID id.RoomID - message string - err error -} - -func (s *stubNoticeSender) SendNotice(_ context.Context, roomID id.RoomID, message string) error { - s.roomID = roomID - s.message = message - return s.err -} - -func TestRadarrConfigDefaultsAndValidation(t *testing.T) { - cfg := RadarrConfig{Enabled: true, Secret: "secret"} - cfg.ApplyDefaults() - if cfg.Path != defaultRadarrWebhookPath { - t.Fatalf("expected default path %q, got %q", defaultRadarrWebhookPath, cfg.Path) - } - if err := cfg.Validate(); err != nil { - t.Fatalf("expected config to validate, got %v", err) - } -} - -func TestRadarrConfigRequiresSecretWhenEnabled(t *testing.T) { - cfg := RadarrConfig{Enabled: true} - if err := cfg.Validate(); err == nil { - t.Fatal("expected missing secret to fail validation") - } -} - -func TestRadarrHandlerRejectsUnauthorizedRequests(t *testing.T) { - handler := &RadarrHandler{ - config: RadarrConfig{Enabled: true, Secret: "secret"}, - resolver: stubRoomResolver{roomID: "!room:test"}, - sender: &stubNoticeSender{}, - } - - req := httptest.NewRequest(http.MethodPost, defaultRadarrWebhookPath, strings.NewReader(`{"eventType":"Test"}`)) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusUnauthorized { - t.Fatalf("expected unauthorized status, got %d", rec.Code) - } -} - -func TestRadarrHandlerDeliversNotice(t *testing.T) { - sender := &stubNoticeSender{} - handler := &RadarrHandler{ - config: RadarrConfig{Enabled: true, Secret: "secret"}, - resolver: stubRoomResolver{roomID: "!room:test"}, - sender: sender, - } - - req := httptest.NewRequest(http.MethodPost, defaultRadarrWebhookPath+"?secret=secret", strings.NewReader(`{"eventType":"Download","movie":{"title":"Dune","year":2021,"imdbId":"tt1160419"},"movieFile":{"quality":"1080p","relativePath":"Dune (2021)/Dune.mkv"},"isUpgrade":false}`)) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusAccepted { - t.Fatalf("expected accepted status, got %d", rec.Code) - } - if sender.roomID != "!room:test" { - t.Fatalf("expected notice sent to management room, got %q", sender.roomID) - } - if !strings.Contains(sender.message, "**Radarr Download**") || !strings.Contains(sender.message, "Dune (2021)") { - t.Fatalf("unexpected message: %s", sender.message) - } -} - -func TestRadarrHandlerReportsAmbiguousManagementRoom(t *testing.T) { - handler := &RadarrHandler{ - config: RadarrConfig{Enabled: true, Secret: "secret"}, - resolver: stubRoomResolver{err: ErrAmbiguousManagementRoom}, - sender: &stubNoticeSender{}, - } - - req := httptest.NewRequest(http.MethodPost, defaultRadarrWebhookPath, strings.NewReader(`{"eventType":"Test"}`)) - req.Header.Set(radarrSecretHeader, "secret") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusConflict { - t.Fatalf("expected conflict status, got %d", rec.Code) - } -} - -func TestRenderRadarrNoticeForTestEvent(t *testing.T) { - msg := renderRadarrNotice(radarrPayload{EventType: "Test"}) - if strings.TrimSpace(msg) != "**Radarr Test**" { - t.Fatalf("unexpected test-event message: %q", msg) - } -} - -func TestRadarrHandlerReturnsBadGatewayOnSendFailure(t *testing.T) { - handler := &RadarrHandler{ - config: RadarrConfig{Enabled: true, Secret: "secret"}, - resolver: stubRoomResolver{roomID: "!room:test"}, - sender: &stubNoticeSender{err: errors.New("send failed")}, - } - - req := httptest.NewRequest(http.MethodPost, defaultRadarrWebhookPath, strings.NewReader(`{"eventType":"Test"}`)) - req.Header.Set(radarrSecretHeader, "secret") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadGateway { - t.Fatalf("expected bad gateway status, got %d", rec.Code) - } -}