diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index e62b828..0000000 --- a/.editorconfig +++ /dev/null @@ -1,6 +0,0 @@ -root = true - -[*] -end_of_line = lf -insert_final_newline = true -charset = utf-8 diff --git a/.gitattributes b/.gitattributes index 6313b56..780e15a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,4 @@ -* text=auto eol=lf +* text=auto +core.autocrlf=false +core.eol=lf +core.filemode=false diff --git a/modules/nixos/services/communication/matrix/default.nix b/modules/nixos/services/communication/matrix/default.nix index 607fa72..c9c11f1 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} = mkMerge [ + ${bridge} = { enable = true; registerToSynapse = true; @@ -43,8 +43,7 @@ }; }; } - conf - ]; + // conf; }; in { options.${namespace}.services.communication.matrix = { @@ -111,13 +110,7 @@ in { (mkMautrix "mautrix-signal" 1 {}) (mkMautrix "mautrix-telegram" 2 {}) (mkMautrix "mautrix-whatsapp" 3 {}) - (mkMautrix "arrtrix" 4 { - settings.network.webhooks.radarr = { - enabled = true; - path = "/_arrtrix/webhooks/radarr"; - secret = ""; - }; - }) + (mkMautrix "arrtrix" 4 {}) { matrix-synapse = { enable = true; diff --git a/modules/nixos/temp/services/arrtrix/default.nix b/modules/nixos/temp/services/arrtrix/default.nix index 618de39..dfd7b32 100644 --- a/modules/nixos/temp/services/arrtrix/default.nix +++ b/modules/nixos/temp/services/arrtrix/default.nix @@ -21,7 +21,7 @@ permissions."*" = "relay"; }; database = { - type = "sqlite3-fk-wal"; + type = "sqlite3"; uri = "file:${dataDir}/arrtrix.db?_txlock=immediate"; }; homeserver = { @@ -40,6 +40,17 @@ 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 { @@ -134,6 +145,19 @@ 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 3fa476f..7958a39 100644 --- a/packages/arrtrix/cmd/arrtrix/main.go +++ b/packages/arrtrix/cmd/arrtrix/main.go @@ -1,8 +1,9 @@ package main import ( + "maunium.net/go/mautrix/bridgev2/matrix/mxmain" + "sneeuwvlok/packages/arrtrix/pkg/connector" - "sneeuwvlok/packages/arrtrix/pkg/runtime" ) var ( @@ -11,7 +12,7 @@ var ( BuildTime = "unknown" ) -var m = runtime.Main{ +var m = mxmain.BridgeMain{ 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 deleted file mode 100644 index c3b11b8..0000000 --- a/packages/arrtrix/pkg/config/config.go +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 84b09df..0000000 --- a/packages/arrtrix/pkg/config/config_test.go +++ /dev/null @@ -1,159 +0,0 @@ -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") - } -} - -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 2cdec34..98a0916 100644 --- a/packages/arrtrix/pkg/connector/config.go +++ b/packages/arrtrix/pkg/connector/config.go @@ -2,13 +2,8 @@ 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 @@ -21,16 +16,3 @@ func upgradeConfig(helper up.Helper) {} func (s *ArrtrixConnector) GetConfig() (string, any, up.Upgrader) { return ExampleConfig, &s.Config, up.SimpleUpgrader(upgradeConfig) } - -func (s *ArrtrixConnector) ValidateConfig() error { - return nil -} - -func (s *ArrtrixConnector) MountRoutes(router *http.ServeMux) error { - if s.Bridge == nil { - return fmt.Errorf("bridge is not initialized") - } - return webhook.MountArr(router, s.Bridge) -} - -var _ bridgev2.ConfigValidatingNetwork = (*ArrtrixConnector)(nil) diff --git a/packages/arrtrix/pkg/connector/connector.go b/packages/arrtrix/pkg/connector/connector.go index 121e94c..e90ed46 100644 --- a/packages/arrtrix/pkg/connector/connector.go +++ b/packages/arrtrix/pkg/connector/connector.go @@ -3,7 +3,6 @@ package connector import ( "context" "fmt" - "net/http" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" @@ -18,7 +17,6 @@ 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 9c11ddf..63a205e 100644 --- a/packages/arrtrix/pkg/connector/example-config.yaml +++ b/packages/arrtrix/pkg/connector/example-config.yaml @@ -1,4 +1,7 @@ # No network-specific config is required yet. # -# Arr-stack webhooks are exposed automatically on the fixed built-in path: -# POST /_arrtrix/webhook +# 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. diff --git a/packages/arrtrix/pkg/matrixcmd/help.go b/packages/arrtrix/pkg/matrixcmd/help.go deleted file mode 100644 index 7da0d84..0000000 --- a/packages/arrtrix/pkg/matrixcmd/help.go +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index b5b325b..0000000 --- a/packages/arrtrix/pkg/matrixcmd/help_test.go +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 1dabfd6..0000000 --- a/packages/arrtrix/pkg/matrixcmd/processor.go +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index 14860c1..0000000 --- a/packages/arrtrix/pkg/onboarding/welcome.go +++ /dev/null @@ -1,137 +0,0 @@ -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 deleted file mode 100644 index de6f42a..0000000 --- a/packages/arrtrix/pkg/onboarding/welcome_test.go +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index f8ffd13..0000000 --- a/packages/arrtrix/pkg/runtime/envconfig.go +++ /dev/null @@ -1,206 +0,0 @@ -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 deleted file mode 100644 index 1cba7b6..0000000 --- a/packages/arrtrix/pkg/runtime/example.go +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 42e1495..0000000 --- a/packages/arrtrix/pkg/runtime/main.go +++ /dev/null @@ -1,418 +0,0 @@ -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 deleted file mode 100644 index f54201b..0000000 --- a/packages/arrtrix/pkg/runtime/main_test.go +++ /dev/null @@ -1,30 +0,0 @@ -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/arr.go b/packages/arrtrix/pkg/webhook/arr.go deleted file mode 100644 index 42e350c..0000000 --- a/packages/arrtrix/pkg/webhook/arr.go +++ /dev/null @@ -1,188 +0,0 @@ -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 ArrWebhookPath = "/_arrtrix/webhook" - -var ( - ErrNoManagementRoom = errors.New("no management room configured") - ErrAmbiguousManagementRoom = errors.New("multiple management rooms configured") -) - -type payload struct { - EventType string `json:"eventType"` - Movie *movie `json:"movie"` - MovieFile *movieFile `json:"movieFile"` - IsUpgrade bool `json:"isUpgrade"` -} - -type movie struct { - Title string `json:"title"` - Year int `json:"year"` - ImdbID string `json:"imdbId"` - TmdbID int `json:"tmdbId"` - Path string `json:"path"` -} - -type movieFile 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 ArrHandler struct { - resolver roomResolver - sender noticeSender -} - -func MountArr(router *http.ServeMux, bridge *bridgev2.Bridge) error { - if bridge == nil { - return fmt.Errorf("bridge is not initialized") - } - handler := &ArrHandler{ - resolver: bridgeRoomResolver{bridge: bridge}, - sender: bridgeNoticeSender{bridge: bridge}, - } - router.Handle(fmt.Sprintf("POST %s", ArrWebhookPath), handler) - return 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(body.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, renderNotice(body)); 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 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("**Arr %s**", body.EventType)} - if title != "Arr" { - lines = append(lines, fmt.Sprintf("Movie: %s", title)) - } - if body.MovieFile != nil && body.MovieFile.Quality != "" { - lines = append(lines, fmt.Sprintf("Quality: %s", body.MovieFile.Quality)) - } - if body.MovieFile != nil && body.MovieFile.RelativePath != "" { - lines = append(lines, fmt.Sprintf("File: `%s`", body.MovieFile.RelativePath)) - } - if body.EventType == "Download" { - lines = append(lines, fmt.Sprintf("Upgrade: %t", body.IsUpgrade)) - } - if body.Movie != nil && body.Movie.ImdbID != "" { - lines = append(lines, fmt.Sprintf("IMDb: `%s`", body.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 = (*ArrHandler)(nil) diff --git a/packages/arrtrix/pkg/webhook/arr_test.go b/packages/arrtrix/pkg/webhook/arr_test.go deleted file mode 100644 index b7ac511..0000000 --- a/packages/arrtrix/pkg/webhook/arr_test.go +++ /dev/null @@ -1,114 +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 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) - } -}