Compare commits
No commits in common. "bbfe6867c8bf9a2452f611e1992d918b705bd2e3" and "eeedb5268a0122d3f1ad26d57f9518e99446a5f9" have entirely different histories.
bbfe6867c8
...
eeedb5268a
21 changed files with 40 additions and 1786 deletions
|
|
@ -1,6 +0,0 @@
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
charset = utf-8
|
|
||||||
5
.gitattributes
vendored
5
.gitattributes
vendored
|
|
@ -1 +1,4 @@
|
||||||
* text=auto eol=lf
|
* text=auto
|
||||||
|
core.autocrlf=false
|
||||||
|
core.eol=lf
|
||||||
|
core.filemode=false
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
keyFile = "/var/lib/element-call/key";
|
keyFile = "/var/lib/element-call/key";
|
||||||
|
|
||||||
mkMautrix = bridge: i: conf: {
|
mkMautrix = bridge: i: conf: {
|
||||||
${bridge} = mkMerge [
|
${bridge} =
|
||||||
{
|
{
|
||||||
enable = true;
|
enable = true;
|
||||||
registerToSynapse = true;
|
registerToSynapse = true;
|
||||||
|
|
@ -43,8 +43,7 @@
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
conf
|
// conf;
|
||||||
];
|
|
||||||
};
|
};
|
||||||
in {
|
in {
|
||||||
options.${namespace}.services.communication.matrix = {
|
options.${namespace}.services.communication.matrix = {
|
||||||
|
|
@ -111,13 +110,7 @@ in {
|
||||||
(mkMautrix "mautrix-signal" 1 {})
|
(mkMautrix "mautrix-signal" 1 {})
|
||||||
(mkMautrix "mautrix-telegram" 2 {})
|
(mkMautrix "mautrix-telegram" 2 {})
|
||||||
(mkMautrix "mautrix-whatsapp" 3 {})
|
(mkMautrix "mautrix-whatsapp" 3 {})
|
||||||
(mkMautrix "arrtrix" 4 {
|
(mkMautrix "arrtrix" 4 {})
|
||||||
settings.network.webhooks.radarr = {
|
|
||||||
enabled = true;
|
|
||||||
path = "/_arrtrix/webhooks/radarr";
|
|
||||||
secret = "";
|
|
||||||
};
|
|
||||||
})
|
|
||||||
{
|
{
|
||||||
matrix-synapse = {
|
matrix-synapse = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
permissions."*" = "relay";
|
permissions."*" = "relay";
|
||||||
};
|
};
|
||||||
database = {
|
database = {
|
||||||
type = "sqlite3-fk-wal";
|
type = "sqlite3";
|
||||||
uri = "file:${dataDir}/arrtrix.db?_txlock=immediate";
|
uri = "file:${dataDir}/arrtrix.db?_txlock=immediate";
|
||||||
};
|
};
|
||||||
homeserver = {
|
homeserver = {
|
||||||
|
|
@ -40,6 +40,17 @@
|
||||||
hs_token = "";
|
hs_token = "";
|
||||||
username_template = "arrtrix_{{.}}";
|
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 = {
|
logging = {
|
||||||
min_level = "info";
|
min_level = "info";
|
||||||
writers = lib.singleton {
|
writers = lib.singleton {
|
||||||
|
|
@ -134,6 +145,19 @@ in {
|
||||||
${lib.getExe cfg.package} --generate-registration --config='${settingsFile}' --registration='${registrationFile}'
|
${lib.getExe cfg.package} --generate-registration --config='${settingsFile}' --registration='${registrationFile}'
|
||||||
fi
|
fi
|
||||||
chmod 640 ${registrationFile}
|
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 = {
|
serviceConfig = {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"maunium.net/go/mautrix/bridgev2/matrix/mxmain"
|
||||||
|
|
||||||
"sneeuwvlok/packages/arrtrix/pkg/connector"
|
"sneeuwvlok/packages/arrtrix/pkg/connector"
|
||||||
"sneeuwvlok/packages/arrtrix/pkg/runtime"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -11,7 +12,7 @@ var (
|
||||||
BuildTime = "unknown"
|
BuildTime = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
var m = runtime.Main{
|
var m = mxmain.BridgeMain{
|
||||||
Name: "arrtrix",
|
Name: "arrtrix",
|
||||||
URL: "https://github.com/chris-kruining/sneeuwvlok",
|
URL: "https://github.com/chris-kruining/sneeuwvlok",
|
||||||
Description: "An Arr-focused Matrix appservice bridge.",
|
Description: "An Arr-focused Matrix appservice bridge.",
|
||||||
|
|
|
||||||
|
|
@ -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{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,13 +2,8 @@ package connector
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
up "go.mau.fi/util/configupgrade"
|
up "go.mau.fi/util/configupgrade"
|
||||||
"maunium.net/go/mautrix/bridgev2"
|
|
||||||
|
|
||||||
"sneeuwvlok/packages/arrtrix/pkg/webhook"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed example-config.yaml
|
//go:embed example-config.yaml
|
||||||
|
|
@ -21,16 +16,3 @@ func upgradeConfig(helper up.Helper) {}
|
||||||
func (s *ArrtrixConnector) GetConfig() (string, any, up.Upgrader) {
|
func (s *ArrtrixConnector) GetConfig() (string, any, up.Upgrader) {
|
||||||
return ExampleConfig, &s.Config, up.SimpleUpgrader(upgradeConfig)
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package connector
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/bridgev2"
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
"maunium.net/go/mautrix/bridgev2/database"
|
"maunium.net/go/mautrix/bridgev2/database"
|
||||||
|
|
@ -18,7 +17,6 @@ type ArrtrixConnector struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ bridgev2.NetworkConnector = (*ArrtrixConnector)(nil)
|
var _ bridgev2.NetworkConnector = (*ArrtrixConnector)(nil)
|
||||||
var _ interface{ MountRoutes(*http.ServeMux) error } = (*ArrtrixConnector)(nil)
|
|
||||||
|
|
||||||
func (s *ArrtrixConnector) GetName() bridgev2.BridgeName {
|
func (s *ArrtrixConnector) GetName() bridgev2.BridgeName {
|
||||||
return bridgev2.BridgeName{
|
return bridgev2.BridgeName{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
# No network-specific config is required yet.
|
# No network-specific config is required yet.
|
||||||
#
|
#
|
||||||
# Arr-stack webhooks are exposed automatically on the fixed built-in path:
|
# Future Arr-specific runtime options, such as webhook handling, can be added
|
||||||
# POST /_arrtrix/webhook
|
# 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.
|
||||||
|
|
|
||||||
|
|
@ -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 <angle brackets> 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()
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -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 <path>] [-r <path>]", m.Name),
|
|
||||||
)
|
|
||||||
|
|
||||||
err := flag.Parse()
|
|
||||||
m.ConfigPath = *configPath
|
|
||||||
m.RegistrationPath = *registrationPath
|
|
||||||
m.SaveConfig = !*dontSaveConfig
|
|
||||||
if err != nil {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
|
||||||
flag.PrintHelp()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case *wantHelp:
|
|
||||||
flag.PrintHelp()
|
|
||||||
os.Exit(0)
|
|
||||||
case *version:
|
|
||||||
fmt.Println(m.ver.VersionDescription)
|
|
||||||
os.Exit(0)
|
|
||||||
case *versionJSON:
|
|
||||||
output := versionJSONOutput{
|
|
||||||
ProgramVersion: m.ver,
|
|
||||||
OS: runtime.GOOS,
|
|
||||||
Arch: runtime.GOARCH,
|
|
||||||
}
|
|
||||||
output.Mautrix.Version = mautrix.Version
|
|
||||||
output.Mautrix.Commit = mautrix.Commit
|
|
||||||
_ = json.NewEncoder(os.Stdout).Encode(output)
|
|
||||||
os.Exit(0)
|
|
||||||
case *writeExampleConfig:
|
|
||||||
m.writeExampleConfig()
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.LoadConfig()
|
|
||||||
if *generateRegistration {
|
|
||||||
m.GenerateRegistration()
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Main) writeExampleConfig() {
|
|
||||||
if *configPath != "-" {
|
|
||||||
if _, err := os.Stat(*configPath); !errors.Is(err, os.ErrNotExist) {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, *configPath, "already exists, please remove it if you want to generate a new example")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
networkExample, _, _ := m.Connector.GetConfig()
|
|
||||||
example := makeExampleConfig(m.Connector.GetName(), networkExample)
|
|
||||||
if *configPath == "-" {
|
|
||||||
fmt.Print(example)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
exerrors.PanicIfNotNil(os.WriteFile(*configPath, []byte(example), 0o600))
|
|
||||||
fmt.Println("Wrote example config to", *configPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Main) GenerateRegistration() {
|
|
||||||
if !m.SaveConfig {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "--no-update is not compatible with --generate-registration")
|
|
||||||
os.Exit(5)
|
|
||||||
}
|
|
||||||
if m.Config.Homeserver.Domain == "example.com" {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "Homeserver domain is not set")
|
|
||||||
os.Exit(20)
|
|
||||||
}
|
|
||||||
|
|
||||||
registration := m.Config.GenerateRegistration()
|
|
||||||
if err := registration.Save(m.RegistrationPath); err != nil {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "Failed to save registration:", err)
|
|
||||||
os.Exit(21)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.saveConfig(); err != nil {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "Failed to save config:", err)
|
|
||||||
os.Exit(22)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Registration generated. See https://docs.mau.fi/bridges/general/registering-appservices.html for instructions on installing the registration.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Main) LoadConfig() {
|
|
||||||
configData, err := os.ReadFile(m.ConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "Failed to read config:", err)
|
|
||||||
os.Exit(10)
|
|
||||||
}
|
|
||||||
|
|
||||||
publicConfig, err := arrconfig.Load(configData)
|
|
||||||
if err != nil {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse config:", err)
|
|
||||||
os.Exit(10)
|
|
||||||
}
|
|
||||||
cfg := publicConfig.Compile()
|
|
||||||
if err = m.loadRegistrationTokens(&cfg); err != nil {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse registration:", err)
|
|
||||||
os.Exit(10)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, networkData, _ := m.Connector.GetConfig()
|
|
||||||
if networkData != nil {
|
|
||||||
if err = cfg.Network.Decode(networkData); err != nil {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse network config:", err)
|
|
||||||
os.Exit(10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Bridge.Backfill = cfg.Backfill
|
|
||||||
if err = updateConfigFromEnv(&cfg, networkData, cfg.EnvConfigPrefix); err != nil {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse environment variables:", err)
|
|
||||||
os.Exit(10)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.PublicConfig = publicConfig
|
|
||||||
m.Config = &cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Main) loadRegistrationTokens(cfg *bridgeconfig.Config) error {
|
|
||||||
if m.RegistrationPath == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(m.RegistrationPath)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var tokens struct {
|
|
||||||
AppToken string `yaml:"as_token"`
|
|
||||||
ServerToken string `yaml:"hs_token"`
|
|
||||||
}
|
|
||||||
if err = yaml.Unmarshal(data, &tokens); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if tokens.AppToken != "" {
|
|
||||||
cfg.AppService.ASToken = tokens.AppToken
|
|
||||||
}
|
|
||||||
if tokens.ServerToken != "" {
|
|
||||||
cfg.AppService.HSToken = tokens.ServerToken
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Main) Init() {
|
|
||||||
var err error
|
|
||||||
m.Log, err = m.Config.Logging.Compile()
|
|
||||||
if err != nil {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "Failed to initialize logger:", err)
|
|
||||||
os.Exit(12)
|
|
||||||
}
|
|
||||||
exzerolog.SetupDefaults(m.Log)
|
|
||||||
|
|
||||||
if err = m.validateConfig(); err != nil {
|
|
||||||
m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Configuration error")
|
|
||||||
m.Log.Info().Msg("See https://docs.mau.fi/faq/field-unconfigured for more info")
|
|
||||||
os.Exit(11)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.Log.Info().
|
|
||||||
Str("name", m.Name).
|
|
||||||
Str("version", m.ver.FormattedVersion).
|
|
||||||
Time("built_at", m.ver.BuildTime).
|
|
||||||
Str("go_version", runtime.Version()).
|
|
||||||
Msg("Initializing bridge")
|
|
||||||
|
|
||||||
m.initDB()
|
|
||||||
m.Matrix = matrix.NewConnector(m.Config)
|
|
||||||
m.Matrix.OnWebsocketReplaced = func() {
|
|
||||||
m.TriggerStop(0)
|
|
||||||
}
|
|
||||||
m.Matrix.IgnoreUnsupportedServer = *ignoreUnsupportedServer
|
|
||||||
m.Bridge = bridgev2.NewBridge("", m.DB, *m.Log, &m.Config.Bridge, m.Matrix, m.Connector, commands.NewProcessor)
|
|
||||||
m.Bridge.Commands = matrixcmd.NewProcessor(m.Bridge, m.Config.ManagementRoomTexts)
|
|
||||||
|
|
||||||
if m.Matrix.EventProcessor != nil {
|
|
||||||
if m.Config.AppService.AsyncTransactions {
|
|
||||||
m.Matrix.EventProcessor.ExecMode = appservice.AsyncLoop
|
|
||||||
} else {
|
|
||||||
m.Matrix.EventProcessor.ExecMode = appservice.Sync
|
|
||||||
}
|
|
||||||
m.Matrix.EventProcessor.PrependHandler(event.StateMember, func(ctx context.Context, evt *event.Event) {
|
|
||||||
onboarding.HandleBotInvite(ctx, m.Bridge, m.Config.ManagementRoomTexts, evt)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
m.Matrix.AS.DoublePuppetValue = m.Name
|
|
||||||
if mounter, ok := m.Connector.(routeMounter); ok {
|
|
||||||
if err = mounter.MountRoutes(m.Matrix.AS.Router); err != nil {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "Failed to mount HTTP routes:", err)
|
|
||||||
os.Exit(13)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.PostInit != nil {
|
|
||||||
m.PostInit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Main) Start() {
|
|
||||||
ctx := m.Log.WithContext(context.Background())
|
|
||||||
if err := m.Bridge.Start(ctx); err != nil {
|
|
||||||
m.Log.Fatal().Err(err).Msg("Failed to start bridge")
|
|
||||||
}
|
|
||||||
if m.PostStart != nil {
|
|
||||||
m.PostStart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Main) Stop() {
|
|
||||||
m.Bridge.StopWithTimeout(5 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Main) WaitForInterrupt() int {
|
|
||||||
interrupts := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(interrupts, os.Interrupt, syscall.SIGTERM)
|
|
||||||
select {
|
|
||||||
case <-interrupts:
|
|
||||||
m.Log.Info().Msg("Interrupt signal received from OS")
|
|
||||||
return 0
|
|
||||||
case exitCode := <-m.manualStop:
|
|
||||||
m.Log.Info().Msg("Internal stop signal received")
|
|
||||||
return exitCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Main) TriggerStop(exitCode int) {
|
|
||||||
select {
|
|
||||||
case m.manualStop <- exitCode:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Main) InitVersion(tag, commit, rawBuildTime string) {
|
|
||||||
m.ver = progver.ProgramVersion{
|
|
||||||
Name: m.Name,
|
|
||||||
URL: m.URL,
|
|
||||||
BaseVersion: m.Version,
|
|
||||||
}.Init(tag, commit, rawBuildTime)
|
|
||||||
mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", m.Name, m.ver.FormattedVersion, mautrix.DefaultUserAgent)
|
|
||||||
m.Version = m.ver.FormattedVersion
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Main) validateConfig() error {
|
|
||||||
switch {
|
|
||||||
case m.Config.Homeserver.Address == "http://example.localhost:8008":
|
|
||||||
return errors.New("homeserver.address not configured")
|
|
||||||
case m.Config.Homeserver.Domain == "example.com":
|
|
||||||
return errors.New("homeserver.domain not configured")
|
|
||||||
case !bridgeconfig.AllowedHomeserverSoftware[m.Config.Homeserver.Software]:
|
|
||||||
return errors.New("invalid value for homeserver.software (use `standard` if you don't know what the field is for)")
|
|
||||||
case m.Config.AppService.ASToken == "This value is generated when generating the registration":
|
|
||||||
return errors.New("appservice.as_token not configured. Did you forget to generate the registration?")
|
|
||||||
case m.Config.AppService.HSToken == "This value is generated when generating the registration":
|
|
||||||
return errors.New("appservice.hs_token not configured. Did you forget to generate the registration?")
|
|
||||||
case m.Config.Database.URI == "postgres://user:password@host/database?sslmode=disable":
|
|
||||||
return errors.New("database.uri not configured")
|
|
||||||
case !m.Config.Bridge.Permissions.IsConfigured():
|
|
||||||
return errors.New("bridge.permissions not configured")
|
|
||||||
case !strings.Contains(m.Config.AppService.FormatUsername("1234567890"), "1234567890"):
|
|
||||||
return errors.New("username template is missing user ID placeholder")
|
|
||||||
default:
|
|
||||||
if validator, ok := m.Connector.(bridgev2.ConfigValidatingNetwork); ok {
|
|
||||||
return validator.ValidateConfig()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Main) initDB() {
|
|
||||||
if m.Config.Database.Type == "sqlite3" {
|
|
||||||
m.Log.WithLevel(zerolog.FatalLevel).Msg("Invalid database type sqlite3. Use sqlite3-fk-wal instead.")
|
|
||||||
os.Exit(14)
|
|
||||||
}
|
|
||||||
if (m.Config.Database.Type == "sqlite3-fk-wal" || m.Config.Database.Type == "litestream") &&
|
|
||||||
m.Config.Database.MaxOpenConns != 1 &&
|
|
||||||
!strings.Contains(m.Config.Database.URI, "_txlock=immediate") {
|
|
||||||
var fixedURI string
|
|
||||||
switch {
|
|
||||||
case !strings.HasPrefix(m.Config.Database.URI, "file:"):
|
|
||||||
fixedURI = fmt.Sprintf("file:%s?_txlock=immediate", m.Config.Database.URI)
|
|
||||||
case !strings.ContainsRune(m.Config.Database.URI, '?'):
|
|
||||||
fixedURI = fmt.Sprintf("%s?_txlock=immediate", m.Config.Database.URI)
|
|
||||||
default:
|
|
||||||
fixedURI = fmt.Sprintf("%s&_txlock=immediate", m.Config.Database.URI)
|
|
||||||
}
|
|
||||||
m.Log.Warn().Str("fixed_uri_example", fixedURI).Msg("Using SQLite without _txlock=immediate is not recommended")
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
m.DB, err = dbutil.NewFromConfig("megabridge/"+m.Name, m.Config.Database, dbutil.ZeroLogger(m.Log.With().Str("db_section", "main").Logger()))
|
|
||||||
if err != nil {
|
|
||||||
m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to initialize database connection")
|
|
||||||
os.Exit(14)
|
|
||||||
}
|
|
||||||
m.DB.IgnoreUnsupportedDatabase = *ignoreUnsupportedDatabase
|
|
||||||
m.DB.IgnoreForeignTables = *ignoreForeignTables
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Main) saveConfig() error {
|
|
||||||
publicConfig := *m.PublicConfig
|
|
||||||
publicConfig.AppService.ASToken = m.Config.AppService.ASToken
|
|
||||||
publicConfig.AppService.HSToken = m.Config.AppService.HSToken
|
|
||||||
|
|
||||||
configData, err := yaml.Marshal(&publicConfig)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(m.ConfigPath, configData, 0o600)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue