Add Arrtrix runtime, config, onboarding, and webhook support

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

View file

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