From 6a4f619229b26f1c4bd58d56d7249985f4a6454f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9D=8E=E9=BE=99=200668001470?=
Date: Fri, 13 Mar 2026 01:15:05 +0800
Subject: [PATCH] fix(irc): accept string auto-join channel config
---
pkg/channels/irc/irc.go | 22 ++++++++++++++
pkg/channels/irc/irc_test.go | 18 +++++++++++
pkg/config/config.go | 18 +++++++----
pkg/config/flexible_string_slice_test.go | 19 ++++++++++++
web/backend/api/config_test.go | 38 ++++++++++++++++++++++++
5 files changed, 110 insertions(+), 5 deletions(-)
create mode 100644 pkg/config/flexible_string_slice_test.go
diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go
index 28c59b540..2f8458f3a 100644
--- a/pkg/channels/irc/irc.go
+++ b/pkg/channels/irc/irc.go
@@ -32,6 +32,7 @@ func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChanne
if cfg.Nick == "" {
return nil, fmt.Errorf("irc nick is required")
}
+ cfg.Channels = normalizeIRCChannels(cfg.Channels)
base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(400),
@@ -192,3 +193,24 @@ func extractHost(server string) string {
}
return server
}
+
+func normalizeIRCChannels(channels config.FlexibleStringSlice) config.FlexibleStringSlice {
+ if len(channels) == 0 {
+ return channels
+ }
+
+ normalized := make(config.FlexibleStringSlice, 0, len(channels))
+ for _, channel := range channels {
+ channel = strings.TrimSpace(channel)
+ if channel == "" {
+ continue
+ }
+ switch channel[0] {
+ case '#', '&', '+', '!':
+ normalized = append(normalized, channel)
+ default:
+ normalized = append(normalized, "#"+channel)
+ }
+ }
+ return normalized
+}
diff --git a/pkg/channels/irc/irc_test.go b/pkg/channels/irc/irc_test.go
index 168252a4d..9ac3042c3 100644
--- a/pkg/channels/irc/irc_test.go
+++ b/pkg/channels/irc/irc_test.go
@@ -1,6 +1,7 @@
package irc
import (
+ "reflect"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -43,6 +44,23 @@ func TestNewIRCChannel(t *testing.T) {
t.Error("new channel should not be running")
}
})
+
+ t.Run("normalizes auto join channels", func(t *testing.T) {
+ cfg := config.IRCConfig{
+ Server: "irc.example.com:6667",
+ Nick: "testbot",
+ Channels: []string{" general ", "#already", "&ops", "+local", "!safe", "", "news"},
+ }
+ ch, err := NewIRCChannel(cfg, msgBus)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ want := config.FlexibleStringSlice{"#general", "#already", "&ops", "+local", "!safe", "#news"}
+ if !reflect.DeepEqual(ch.config.Channels, want) {
+ t.Fatalf("Channels = %#v, want %#v", ch.config.Channels, want)
+ }
+ })
}
func TestExtractHost(t *testing.T) {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 7a7edb489..08e89864a 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -29,6 +29,13 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
return nil
}
+ // Accept a single string so config patch APIs can submit comma-separated values.
+ var s string
+ if err := json.Unmarshal(data, &s); err == nil {
+ *f = parseFlexibleStringSlice(s)
+ return nil
+ }
+
// Try []interface{} to handle mixed types
var raw []any
if err := json.Unmarshal(data, &raw); err != nil {
@@ -57,21 +64,22 @@ func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
*f = nil
return nil
}
+ *f = parseFlexibleStringSlice(string(text))
+ return nil
+}
- s := string(text)
- // Replace Chinese comma with English comma, then split
+func parseFlexibleStringSlice(s string) FlexibleStringSlice {
s = strings.ReplaceAll(s, ",", ",")
parts := strings.Split(s, ",")
- result := make([]string, 0, len(parts))
+ result := make(FlexibleStringSlice, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
- *f = result
- return nil
+ return result
}
type Config struct {
diff --git a/pkg/config/flexible_string_slice_test.go b/pkg/config/flexible_string_slice_test.go
new file mode 100644
index 000000000..a5bb1763d
--- /dev/null
+++ b/pkg/config/flexible_string_slice_test.go
@@ -0,0 +1,19 @@
+package config
+
+import (
+ "encoding/json"
+ "reflect"
+ "testing"
+)
+
+func TestFlexibleStringSliceUnmarshalJSON_String(t *testing.T) {
+ var got FlexibleStringSlice
+ if err := json.Unmarshal([]byte(`"general, #ops,dev"`), &got); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ want := FlexibleStringSlice{"general", "#ops", "dev"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("FlexibleStringSlice = %#v, want %#v", got, want)
+ }
+}
diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go
index 29811e37e..1daa4a835 100644
--- a/web/backend/api/config_test.go
+++ b/web/backend/api/config_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"net/http"
"net/http/httptest"
+ "reflect"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
@@ -86,3 +87,40 @@ func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) {
t.Fatalf("model_list[0].api_base = %q, want empty string", got)
}
}
+
+func TestHandlePatchConfig_AcceptsFlexibleStringSliceString(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "channels": {
+ "irc": {
+ "enabled": true,
+ "server": "irc.example.com:6667",
+ "nick": "testbot",
+ "channels": "general, #ops,dev"
+ }
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+
+ want := config.FlexibleStringSlice{"general", "#ops", "dev"}
+ if !reflect.DeepEqual(cfg.Channels.IRC.Channels, want) {
+ t.Fatalf("channels.irc.channels = %#v, want %#v", cfg.Channels.IRC.Channels, want)
+ }
+}