From e55ddf2e24c8c341b163a2141fe6eddfca6f17f6 Mon Sep 17 00:00:00 2001 From: Carlos Prados Date: Sun, 14 Jun 2026 11:52:32 +0200 Subject: [PATCH 1/2] feat(config): add RegisterChannelSettings hook for out-of-tree channels Expose a public registration hook so external packages that register a channel factory via channels.RegisterFactory can also register the channel's settings struct prototype. Without this, InitChannelList rejects any channel type absent from the private channelSettingsFactory map, making out-of-tree channels impossible without forking. The map access is now guarded by a RWMutex. This is additive and changes no existing behavior. --- pkg/config/config_channel.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pkg/config/config_channel.go b/pkg/config/config_channel.go index 63a9b0d8..b71ec798 100644 --- a/pkg/config/config_channel.go +++ b/pkg/config/config_channel.go @@ -7,6 +7,7 @@ import ( "reflect" "strconv" "strings" + "sync" "github.com/caarlos0/env/v11" "gopkg.in/yaml.v3" @@ -655,6 +656,8 @@ func filterSecureFields(r RawNode, secureFields map[string]struct{}) RawNode { // channelSettingsFactory maps channel type to a zero-value prototype of the // corresponding Settings struct. InitChannelList uses reflect.New to create // fresh instances, avoiding repeated closure boilerplate. +var channelSettingsMu sync.RWMutex + var channelSettingsFactory = map[string]any{ ChannelPico: (PicoSettings{}), ChannelPicoClient: (PicoClientSettings{}), @@ -679,10 +682,24 @@ var channelSettingsFactory = map[string]any{ ChannelSlackWebHook: (SlackWebhookSettings{}), } +// RegisterChannelSettings registers a settings struct prototype for a custom +// channel type. External packages (out-of-tree channels registered via +// channels.RegisterFactory) call this from an init() so their channel type +// passes config validation (isValidChannelType) and its settings block decodes +// into the right struct (newChannelSettings). The prototype must be a struct +// value, e.g. RegisterChannelSettings("my_channel", MyChannelSettings{}). +func RegisterChannelSettings(channelType string, prototype any) { + channelSettingsMu.Lock() + defer channelSettingsMu.Unlock() + channelSettingsFactory[channelType] = prototype +} + // newChannelSettings creates a fresh zero-value pointer for the given channel type. // Returns nil if the type is not registered. func newChannelSettings(channelType string) any { + channelSettingsMu.RLock() proto, ok := channelSettingsFactory[channelType] + channelSettingsMu.RUnlock() if !ok { return nil } @@ -691,7 +708,9 @@ func newChannelSettings(channelType string) any { // isValidChannelType returns true if the channel type is a known, registered type. func isValidChannelType(channelType string) bool { + channelSettingsMu.RLock() _, ok := channelSettingsFactory[channelType] + channelSettingsMu.RUnlock() return ok } From 9eadffda1e7d026122b5e4d800068b79c0b22847 Mon Sep 17 00:00:00 2001 From: Carlos Prados Date: Sun, 14 Jun 2026 12:07:54 +0200 Subject: [PATCH 2/2] test(config): cover RegisterChannelSettings hook Unit tests prove an out-of-tree channel type becomes valid and decodable after RegisterChannelSettings, including the full InitChannelList -> GetDecoded path that previously failed with "unknown type". --- pkg/config/register_channel_settings_test.go | 54 ++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 pkg/config/register_channel_settings_test.go diff --git a/pkg/config/register_channel_settings_test.go b/pkg/config/register_channel_settings_test.go new file mode 100644 index 00000000..3ecdd74e --- /dev/null +++ b/pkg/config/register_channel_settings_test.go @@ -0,0 +1,54 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// customChannelSettings stands in for an out-of-tree channel's settings struct. +type customChannelSettings struct { + Token string `json:"token"` +} + +// TestRegisterChannelSettings verifies the public registration hook makes a +// previously-unknown channel type valid and decodable — the behavior out-of-tree +// channels rely on (they call RegisterChannelSettings from init()). +func TestRegisterChannelSettings(t *testing.T) { + const typ = "custom_test_channel" + + assert.False(t, isValidChannelType(typ), "type should be unknown before registration") + + RegisterChannelSettings(typ, customChannelSettings{}) + + assert.True(t, isValidChannelType(typ), "type should be valid after registration") + + got := newChannelSettings(typ) + _, ok := got.(*customChannelSettings) + assert.Truef(t, ok, "newChannelSettings(%q) = %T, want *customChannelSettings", typ, got) +} + +// TestRegisterChannelSettings_InitChannelList verifies that a config carrying a +// registered out-of-tree channel type passes InitChannelList and decodes its +// settings — the full path that previously errored with "unknown type". +func TestRegisterChannelSettings_InitChannelList(t *testing.T) { + const typ = "custom_initlist_channel" + RegisterChannelSettings(typ, customChannelSettings{}) + + channels := ChannelsConfig{ + "mychan": { + Type: typ, + Enabled: true, + Settings: RawNode(`{"token":"secret-123"}`), + }, + } + + require.NoError(t, InitChannelList(channels)) + + decoded, err := channels["mychan"].GetDecoded() + require.NoError(t, err) + cfg, ok := decoded.(*customChannelSettings) + require.True(t, ok) + assert.Equal(t, "secret-123", cfg.Token) +}