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,60 @@
package matrixcmd
import (
"fmt"
"sort"
"strings"
)
func NewHelpHandler(proc *Processor) Handler {
return NewHandler(Meta{
Name: "help",
Description: "Show this help message.",
}, func(ctx *Context) {
ctx.Reply(formatHelp(proc, ctx))
})
}
func formatHelp(proc *Processor, ctx *Context) string {
var builder strings.Builder
switch {
case ctx.RoomID == ctx.User.ManagementRoom:
builder.WriteString(fmt.Sprintf("This is your management room: prefixing commands with `%s` is not required.\n", ctx.Bridge.Config.CommandPrefix))
case ctx.Portal != nil:
builder.WriteString(fmt.Sprintf("**This is a portal room**: you must always prefix commands with `%s`. Management commands will not be bridged.\n", ctx.Bridge.Config.CommandPrefix))
default:
builder.WriteString(fmt.Sprintf("This is not your management room: prefixing commands with `%s` is required.\n", ctx.Bridge.Config.CommandPrefix))
}
builder.WriteString("Parameters in [square brackets] are optional, while parameters in <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()
}

View file

@ -0,0 +1,42 @@
package matrixcmd
import (
"strings"
"testing"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/id"
)
func TestFormatHelpManagementRoom(t *testing.T) {
roomID := id.RoomID("!arrtrix:test")
proc := &Processor{
texts: bridgeconfig.ManagementRoomTexts{AdditionalHelp: "Extra help text."},
command: make(map[string]Handler),
alias: make(map[string]string),
}
proc.Add(NewHelpHandler(proc))
out := formatHelp(proc, &Context{
Bridge: &bridgev2.Bridge{
Config: &bridgeconfig.BridgeConfig{
CommandPrefix: "!arr",
},
},
RoomID: roomID,
User: &bridgev2.User{User: &database.User{ManagementRoom: roomID}},
Processor: proc,
})
for _, fragment := range []string{
"prefixing commands with `!arr` is not required",
"**help** - Show this help message.",
"Extra help text.",
} {
if !strings.Contains(out, fragment) {
t.Fatalf("expected help output to contain %q, got:\n%s", fragment, out)
}
}
}

View file

@ -0,0 +1,204 @@
package matrixcmd
import (
"context"
"fmt"
"runtime/debug"
"sort"
"strings"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
type Handler interface {
Meta() Meta
Run(*Context)
}
type Meta struct {
Name string
Description string
Usage string
Aliases []string
}
type HandlerFunc struct {
meta Meta
run func(*Context)
}
func NewHandler(meta Meta, run func(*Context)) Handler {
return HandlerFunc{meta: meta, run: run}
}
func (h HandlerFunc) Meta() Meta {
return h.meta
}
func (h HandlerFunc) Run(ctx *Context) {
h.run(ctx)
}
type Processor struct {
bridge *bridgev2.Bridge
bot bridgev2.MatrixAPI
texts bridgeconfig.ManagementRoomTexts
command map[string]Handler
alias map[string]string
order []string
}
type Context struct {
Bridge *bridgev2.Bridge
Bot bridgev2.MatrixAPI
RoomID id.RoomID
OrigRoomID id.RoomID
EventID id.EventID
ReplyTo id.EventID
User *bridgev2.User
Portal *bridgev2.Portal
Command string
Args []string
RawArgs string
Ctx context.Context
Log *zerolog.Logger
Processor *Processor
}
var _ bridgev2.CommandProcessor = (*Processor)(nil)
func NewProcessor(bridge *bridgev2.Bridge, texts bridgeconfig.ManagementRoomTexts) *Processor {
proc := &Processor{
bridge: bridge,
bot: bridge.Bot,
texts: texts,
command: make(map[string]Handler),
alias: make(map[string]string),
}
proc.Add(NewHelpHandler(proc))
return proc
}
func (p *Processor) Add(handler Handler) {
meta := handler.Meta()
p.command[meta.Name] = handler
p.order = append(p.order, meta.Name)
for _, alias := range meta.Aliases {
p.alias[alias] = meta.Name
}
}
func (p *Processor) Handlers() []Handler {
names := append([]string(nil), p.order...)
sort.Strings(names)
handlers := make([]Handler, 0, len(names))
for _, name := range names {
handler, ok := p.command[name]
if ok {
handlers = append(handlers, handler)
}
}
return handlers
}
func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user *bridgev2.User, message string, replyTo id.EventID) {
ms := &bridgev2.MessageStatus{
Step: status.MsgStepCommand,
Status: event.MessageStatusSuccess,
}
logCopy := zerolog.Ctx(ctx).With().Logger()
log := &logCopy
defer func() {
statusInfo := &bridgev2.MessageStatusEventInfo{
RoomID: roomID,
SourceEventID: eventID,
EventType: event.EventMessage,
Sender: user.MXID,
}
if recovered := recover(); recovered != nil {
logEvt := log.Error().Bytes(zerolog.ErrorStackFieldName, debug.Stack())
if err, ok := recovered.(error); ok {
logEvt = logEvt.Err(err)
ms.InternalError = err
} else {
logEvt = logEvt.Any(zerolog.ErrorFieldName, recovered)
ms.InternalError = fmt.Errorf("%v", recovered)
}
logEvt.Msg("Panic in arrtrix Matrix command handler")
ms.Status = event.MessageStatusFail
ms.IsCertain = true
ms.ErrorAsMessage = true
}
p.bridge.Matrix.SendMessageStatus(ctx, ms, statusInfo)
}()
args := strings.Fields(message)
if len(args) == 0 {
args = []string{"unknown-command"}
}
commandName := strings.ToLower(args[0])
if actual, ok := p.alias[commandName]; ok {
commandName = actual
}
portal, err := p.bridge.GetPortalByMXID(ctx, roomID)
if err != nil {
log.Err(err).Msg("Failed to get portal")
}
commandCtx := &Context{
Bridge: p.bridge,
Bot: p.bot,
RoomID: roomID,
OrigRoomID: roomID,
EventID: eventID,
ReplyTo: replyTo,
User: user,
Portal: portal,
Command: commandName,
Args: args[1:],
RawArgs: strings.TrimSpace(strings.TrimPrefix(message, args[0])),
Ctx: ctx,
Log: log,
Processor: p,
}
handler, ok := p.command[commandName]
if !ok {
log.Debug().Str("mx_command", commandName).Msg("Received unknown Matrix room command")
commandCtx.Reply("Unknown command, use the `help` command for help.")
return
}
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
return c.Str("mx_command", commandName)
})
log.Debug().Msg("Received Matrix room command")
handler.Run(commandCtx)
}
func (c *Context) Reply(message string, args ...any) {
message = strings.ReplaceAll(message, "$cmdprefix ", c.Bridge.Config.CommandPrefix+" ")
if len(args) > 0 {
message = fmt.Sprintf(message, args...)
}
content := format.RenderMarkdown(message, true, false)
content.MsgType = event.MsgNotice
if _, err := c.Bot.SendMessage(c.Ctx, c.OrigRoomID, event.EventMessage, &event.Content{Parsed: &content}, nil); err != nil {
c.Log.Err(err).Msg("Failed to reply to Matrix room command")
}
}