fix(irc): normalize channel names from flexible config

This commit is contained in:
李龙 0668001470 2026-03-16 10:04:18 +08:00
parent ba83a5d256
commit 6bf87d19f4
5 changed files with 1535 additions and 1563 deletions

View file

@ -1,216 +1,216 @@
package irc package irc
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"strings" "strings"
"github.com/ergochat/irc-go/ircevent" "github.com/ergochat/irc-go/ircevent"
"github.com/ergochat/irc-go/ircmsg" "github.com/ergochat/irc-go/ircmsg"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
) )
// IRCChannel implements the Channel interface for IRC servers. // IRCChannel implements the Channel interface for IRC servers.
type IRCChannel struct { type IRCChannel struct {
*channels.BaseChannel *channels.BaseChannel
config config.IRCConfig config config.IRCConfig
conn *ircevent.Connection conn *ircevent.Connection
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
} }
// NewIRCChannel creates a new IRC channel. // NewIRCChannel creates a new IRC channel.
func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) { func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) {
if cfg.Server == "" { if cfg.Server == "" {
return nil, fmt.Errorf("irc server is required") return nil, fmt.Errorf("irc server is required")
} }
if cfg.Nick == "" { if cfg.Nick == "" {
return nil, fmt.Errorf("irc nick is required") return nil, fmt.Errorf("irc nick is required")
} }
cfg.Channels = normalizeIRCChannels(cfg.Channels) cfg.Channels = normalizeIRCChannels(cfg.Channels)
base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom, base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(400), channels.WithMaxMessageLength(400),
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID), channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
return &IRCChannel{ return &IRCChannel{
BaseChannel: base, BaseChannel: base,
config: cfg, config: cfg,
}, nil }, nil
} }
// Start connects to the IRC server and begins listening. // Start connects to the IRC server and begins listening.
func (c *IRCChannel) Start(ctx context.Context) error { func (c *IRCChannel) Start(ctx context.Context) error {
logger.InfoC("irc", "Starting IRC channel") logger.InfoC("irc", "Starting IRC channel")
c.ctx, c.cancel = context.WithCancel(ctx) c.ctx, c.cancel = context.WithCancel(ctx)
user := c.config.User user := c.config.User
if user == "" { if user == "" {
user = c.config.Nick user = c.config.Nick
} }
realName := c.config.RealName realName := c.config.RealName
if realName == "" { if realName == "" {
realName = c.config.Nick realName = c.config.Nick
} }
caps := []string(c.config.RequestCaps) caps := []string(c.config.RequestCaps)
if len(caps) == 0 { if len(caps) == 0 {
caps = []string{"server-time", "message-tags"} caps = []string{"server-time", "message-tags"}
} }
conn := &ircevent.Connection{ conn := &ircevent.Connection{
Server: c.config.Server, Server: c.config.Server,
Nick: c.config.Nick, Nick: c.config.Nick,
User: user, User: user,
RealName: realName, RealName: realName,
Password: c.config.Password, Password: c.config.Password,
UseTLS: c.config.TLS, UseTLS: c.config.TLS,
RequestCaps: caps, RequestCaps: caps,
QuitMessage: "Goodbye", QuitMessage: "Goodbye",
Debug: false, Debug: false,
Log: nil, Log: nil,
} }
if c.config.TLS { if c.config.TLS {
conn.TLSConfig = &tls.Config{ conn.TLSConfig = &tls.Config{
ServerName: extractHost(c.config.Server), ServerName: extractHost(c.config.Server),
} }
} }
// SASL auth (takes priority over NickServ) // SASL auth (takes priority over NickServ)
if c.config.SASLUser != "" && c.config.SASLPassword != "" { if c.config.SASLUser != "" && c.config.SASLPassword != "" {
conn.SASLLogin = c.config.SASLUser conn.SASLLogin = c.config.SASLUser
conn.SASLPassword = c.config.SASLPassword conn.SASLPassword = c.config.SASLPassword
} }
// Register event handlers // Register event handlers
conn.AddConnectCallback(func(e ircmsg.Message) { conn.AddConnectCallback(func(e ircmsg.Message) {
c.onConnect(conn) c.onConnect(conn)
}) })
conn.AddCallback("PRIVMSG", func(e ircmsg.Message) { conn.AddCallback("PRIVMSG", func(e ircmsg.Message) {
c.onPrivmsg(conn, e) c.onPrivmsg(conn, e)
}) })
if err := conn.Connect(); err != nil { if err := conn.Connect(); err != nil {
return fmt.Errorf("irc connect failed: %w", err) return fmt.Errorf("irc connect failed: %w", err)
} }
c.conn = conn c.conn = conn
// ircevent.Connection.Loop() handles reconnection internally. // ircevent.Connection.Loop() handles reconnection internally.
go conn.Loop() go conn.Loop()
c.SetRunning(true) c.SetRunning(true)
logger.InfoCF("irc", "IRC channel started", map[string]any{ logger.InfoCF("irc", "IRC channel started", map[string]any{
"server": c.config.Server, "server": c.config.Server,
"nick": c.config.Nick, "nick": c.config.Nick,
}) })
return nil return nil
} }
// Stop disconnects from the IRC server. // Stop disconnects from the IRC server.
func (c *IRCChannel) Stop(ctx context.Context) error { func (c *IRCChannel) Stop(ctx context.Context) error {
logger.InfoC("irc", "Stopping IRC channel") logger.InfoC("irc", "Stopping IRC channel")
c.SetRunning(false) c.SetRunning(false)
if c.conn != nil { if c.conn != nil {
c.conn.Quit() c.conn.Quit()
} }
if c.cancel != nil { if c.cancel != nil {
c.cancel() c.cancel()
} }
logger.InfoC("irc", "IRC channel stopped") logger.InfoC("irc", "IRC channel stopped")
return nil return nil
} }
// Send sends a message to an IRC channel or user. // Send sends a message to an IRC channel or user.
func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() { if !c.IsRunning() {
return channels.ErrNotRunning return channels.ErrNotRunning
} }
target := msg.ChatID target := msg.ChatID
if target == "" { if target == "" {
return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
} }
if strings.TrimSpace(msg.Content) == "" { if strings.TrimSpace(msg.Content) == "" {
return nil return nil
} }
// Send each line separately (IRC is line-oriented) // Send each line separately (IRC is line-oriented)
lines := strings.Split(msg.Content, "\n") lines := strings.Split(msg.Content, "\n")
for _, line := range lines { for _, line := range lines {
line = strings.TrimRight(line, "\r") line = strings.TrimRight(line, "\r")
if line == "" { if line == "" {
continue continue
} }
c.conn.Privmsg(target, line) c.conn.Privmsg(target, line)
} }
logger.DebugCF("irc", "Message sent", map[string]any{ logger.DebugCF("irc", "Message sent", map[string]any{
"target": target, "target": target,
"lines": len(lines), "lines": len(lines),
}) })
return nil return nil
} }
// StartTyping implements channels.TypingCapable using IRCv3 +typing client tag. // StartTyping implements channels.TypingCapable using IRCv3 +typing client tag.
// Requires typing.enabled in config and server support for message-tags capability. // Requires typing.enabled in config and server support for message-tags capability.
func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
noop := func() {} noop := func() {}
if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil { if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil {
return noop, nil return noop, nil
} }
// Check if server supports message-tags (required for TAGMSG) // Check if server supports message-tags (required for TAGMSG)
if _, ok := c.conn.AcknowledgedCaps()["message-tags"]; !ok { if _, ok := c.conn.AcknowledgedCaps()["message-tags"]; !ok {
return noop, nil return noop, nil
} }
c.conn.SendWithTags(map[string]string{"+typing": "active"}, "TAGMSG", chatID) c.conn.SendWithTags(map[string]string{"+typing": "active"}, "TAGMSG", chatID)
return func() { return func() {
if c.IsRunning() && c.conn != nil { if c.IsRunning() && c.conn != nil {
c.conn.SendWithTags(map[string]string{"+typing": "done"}, "TAGMSG", chatID) c.conn.SendWithTags(map[string]string{"+typing": "done"}, "TAGMSG", chatID)
} }
}, nil }, nil
} }
// extractHost returns the hostname portion of a host:port string. // extractHost returns the hostname portion of a host:port string.
func extractHost(server string) string { func extractHost(server string) string {
host, _, found := strings.Cut(server, ":") host, _, found := strings.Cut(server, ":")
if found { if found {
return host return host
} }
return server return server
} }
func normalizeIRCChannels(channels config.FlexibleStringSlice) config.FlexibleStringSlice { func normalizeIRCChannels(channels config.FlexibleStringSlice) config.FlexibleStringSlice {
if len(channels) == 0 { if len(channels) == 0 {
return channels return channels
} }
normalized := make(config.FlexibleStringSlice, 0, len(channels)) normalized := make(config.FlexibleStringSlice, 0, len(channels))
for _, channel := range channels { for _, channel := range channels {
channel = strings.TrimSpace(channel) channel = strings.TrimSpace(channel)
if channel == "" { if channel == "" {
continue continue
} }
switch channel[0] { switch channel[0] {
case '#', '&', '+', '!': case '#', '&', '+', '!':
normalized = append(normalized, channel) normalized = append(normalized, channel)
default: default:
normalized = append(normalized, "#"+channel) normalized = append(normalized, "#"+channel)
} }
} }
return normalized return normalized
} }

View file

@ -1,163 +1,163 @@
package irc package irc
import ( import (
"reflect" "reflect"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
func TestNewIRCChannel(t *testing.T) { func TestNewIRCChannel(t *testing.T) {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
t.Run("missing server", func(t *testing.T) { t.Run("missing server", func(t *testing.T) {
cfg := config.IRCConfig{Nick: "bot"} cfg := config.IRCConfig{Nick: "bot"}
_, err := NewIRCChannel(cfg, msgBus) _, err := NewIRCChannel(cfg, msgBus)
if err == nil { if err == nil {
t.Error("expected error for missing server, got nil") t.Error("expected error for missing server, got nil")
} }
}) })
t.Run("missing nick", func(t *testing.T) { t.Run("missing nick", func(t *testing.T) {
cfg := config.IRCConfig{Server: "irc.example.com:6667"} cfg := config.IRCConfig{Server: "irc.example.com:6667"}
_, err := NewIRCChannel(cfg, msgBus) _, err := NewIRCChannel(cfg, msgBus)
if err == nil { if err == nil {
t.Error("expected error for missing nick, got nil") t.Error("expected error for missing nick, got nil")
} }
}) })
t.Run("valid config", func(t *testing.T) { t.Run("valid config", func(t *testing.T) {
cfg := config.IRCConfig{ cfg := config.IRCConfig{
Server: "irc.example.com:6667", Server: "irc.example.com:6667",
Nick: "testbot", Nick: "testbot",
Channels: []string{"#test"}, Channels: []string{"#test"},
} }
ch, err := NewIRCChannel(cfg, msgBus) ch, err := NewIRCChannel(cfg, msgBus)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if ch.Name() != "irc" { if ch.Name() != "irc" {
t.Errorf("Name() = %q, want %q", ch.Name(), "irc") t.Errorf("Name() = %q, want %q", ch.Name(), "irc")
} }
if ch.IsRunning() { if ch.IsRunning() {
t.Error("new channel should not be running") t.Error("new channel should not be running")
} }
}) })
t.Run("normalizes auto join channels", func(t *testing.T) { t.Run("normalizes auto join channels", func(t *testing.T) {
cfg := config.IRCConfig{ cfg := config.IRCConfig{
Server: "irc.example.com:6667", Server: "irc.example.com:6667",
Nick: "testbot", Nick: "testbot",
Channels: []string{" general ", "#already", "&ops", "+local", "!safe", "", "news"}, Channels: []string{" general ", "#already", "&ops", "+local", "!safe", "", "news"},
} }
ch, err := NewIRCChannel(cfg, msgBus) ch, err := NewIRCChannel(cfg, msgBus)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
want := config.FlexibleStringSlice{"#general", "#already", "&ops", "+local", "!safe", "#news"} want := config.FlexibleStringSlice{"#general", "#already", "&ops", "+local", "!safe", "#news"}
if !reflect.DeepEqual(ch.config.Channels, want) { if !reflect.DeepEqual(ch.config.Channels, want) {
t.Fatalf("Channels = %#v, want %#v", ch.config.Channels, want) t.Fatalf("Channels = %#v, want %#v", ch.config.Channels, want)
} }
}) })
} }
func TestExtractHost(t *testing.T) { func TestExtractHost(t *testing.T) {
tests := []struct { tests := []struct {
server string server string
want string want string
}{ }{
{"irc.libera.chat:6697", "irc.libera.chat"}, {"irc.libera.chat:6697", "irc.libera.chat"},
{"localhost:6667", "localhost"}, {"localhost:6667", "localhost"},
{"irc.example.com", "irc.example.com"}, {"irc.example.com", "irc.example.com"},
{"", ""}, {"", ""},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.server, func(t *testing.T) { t.Run(tt.server, func(t *testing.T) {
got := extractHost(tt.server) got := extractHost(tt.server)
if got != tt.want { if got != tt.want {
t.Errorf("extractHost(%q) = %q, want %q", tt.server, got, tt.want) t.Errorf("extractHost(%q) = %q, want %q", tt.server, got, tt.want)
} }
}) })
} }
} }
func TestNickMentionedAt(t *testing.T) { func TestNickMentionedAt(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
content string content string
nick string nick string
want int want int
}{ }{
{"colon prefix", "bot: hello", "bot", 0}, {"colon prefix", "bot: hello", "bot", 0},
{"comma prefix", "bot, hello", "bot", 0}, {"comma prefix", "bot, hello", "bot", 0},
{"case insensitive", "BOT: hello", "bot", 0}, {"case insensitive", "BOT: hello", "bot", 0},
{"word boundary mid", "hey bot what's up", "bot", 4}, {"word boundary mid", "hey bot what's up", "bot", 4},
{"no mention", "hello world", "bot", -1}, {"no mention", "hello world", "bot", -1},
{"substring mismatch", "robotics are cool", "bot", -1}, {"substring mismatch", "robotics are cool", "bot", -1},
{"nick at end", "hello bot", "bot", 6}, {"nick at end", "hello bot", "bot", 6},
{"empty content", "", "bot", -1}, {"empty content", "", "bot", -1},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := nickMentionedAt(tt.content, tt.nick) got := nickMentionedAt(tt.content, tt.nick)
if got != tt.want { if got != tt.want {
t.Errorf("nickMentionedAt(%q, %q) = %d, want %d", tt.content, tt.nick, got, tt.want) t.Errorf("nickMentionedAt(%q, %q) = %d, want %d", tt.content, tt.nick, got, tt.want)
} }
}) })
} }
} }
func TestIsBotMentioned(t *testing.T) { func TestIsBotMentioned(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
content string content string
nick string nick string
want bool want bool
}{ }{
{"colon prefix", "bot: hello", "bot", true}, {"colon prefix", "bot: hello", "bot", true},
{"comma prefix", "bot, hello", "bot", true}, {"comma prefix", "bot, hello", "bot", true},
{"case insensitive", "BOT: hello", "bot", true}, {"case insensitive", "BOT: hello", "bot", true},
{"word boundary mid", "hey bot what's up", "bot", true}, {"word boundary mid", "hey bot what's up", "bot", true},
{"no mention", "hello world", "bot", false}, {"no mention", "hello world", "bot", false},
{"substring mismatch", "robotics are cool", "bot", false}, {"substring mismatch", "robotics are cool", "bot", false},
{"nick at end", "hello bot", "bot", true}, {"nick at end", "hello bot", "bot", true},
{"empty content", "", "bot", false}, {"empty content", "", "bot", false},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := isBotMentioned(tt.content, tt.nick) got := isBotMentioned(tt.content, tt.nick)
if got != tt.want { if got != tt.want {
t.Errorf("isBotMentioned(%q, %q) = %v, want %v", tt.content, tt.nick, got, tt.want) t.Errorf("isBotMentioned(%q, %q) = %v, want %v", tt.content, tt.nick, got, tt.want)
} }
}) })
} }
} }
func TestStripBotMention(t *testing.T) { func TestStripBotMention(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
content string content string
nick string nick string
want string want string
}{ }{
{"colon prefix", "bot: hello there", "bot", "hello there"}, {"colon prefix", "bot: hello there", "bot", "hello there"},
{"comma prefix", "bot, help me", "bot", "help me"}, {"comma prefix", "bot, help me", "bot", "help me"},
{"case insensitive", "BOT: hello", "bot", "hello"}, {"case insensitive", "BOT: hello", "bot", "hello"},
{"no prefix match", "hello bot", "bot", "hello bot"}, {"no prefix match", "hello bot", "bot", "hello bot"},
{"only prefix", "bot:", "bot", ""}, {"only prefix", "bot:", "bot", ""},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := stripBotMention(tt.content, tt.nick) got := stripBotMention(tt.content, tt.nick)
if got != tt.want { if got != tt.want {
t.Errorf("stripBotMention(%q, %q) = %q, want %q", tt.content, tt.nick, got, tt.want) t.Errorf("stripBotMention(%q, %q) = %q, want %q", tt.content, tt.nick, got, tt.want)
} }
}) })
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,19 @@
package config package config
import ( import (
"encoding/json" "encoding/json"
"reflect" "reflect"
"testing" "testing"
) )
func TestFlexibleStringSliceUnmarshalJSON_String(t *testing.T) { func TestFlexibleStringSliceUnmarshalJSON_String(t *testing.T) {
var got FlexibleStringSlice var got FlexibleStringSlice
if err := json.Unmarshal([]byte(`"general, #opsdev"`), &got); err != nil { if err := json.Unmarshal([]byte(`"general, #opsdev"`), &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err) t.Fatalf("Unmarshal() error = %v", err)
} }
want := FlexibleStringSlice{"general", "#ops", "dev"} want := FlexibleStringSlice{"general", "#ops", "dev"}
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
t.Fatalf("FlexibleStringSlice = %#v, want %#v", got, want) t.Fatalf("FlexibleStringSlice = %#v, want %#v", got, want)
} }
} }

View file

@ -1,126 +1,88 @@
package api package api
import ( import (
"bytes" "bytes"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"reflect" "testing"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/config" )
)
func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {
func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t)
configPath, cleanup := setupOAuthTestEnv(t) defer cleanup()
defer cleanup()
h := NewHandler(configPath)
h := NewHandler(configPath) mux := http.NewServeMux()
mux := http.NewServeMux() h.RegisterRoutes(mux)
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ "agents": {
"agents": { "defaults": {
"defaults": { "workspace": "~/.picoclaw/workspace"
"workspace": "~/.picoclaw/workspace" }
} },
}, "model_list": [
"model_list": [ {
{ "model_name": "custom-default",
"model_name": "custom-default", "model": "openai/gpt-4o",
"model": "openai/gpt-4o", "api_key": "sk-default"
"api_key": "sk-default" }
} ]
] }`))
}`)) req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
rec := httptest.NewRecorder() mux.ServeHTTP(rec, req)
mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK {
if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) }
}
cfg, err := config.LoadConfig(configPath)
cfg, err := config.LoadConfig(configPath) if err != nil {
if err != nil { t.Fatalf("LoadConfig() error = %v", err)
t.Fatalf("LoadConfig() error = %v", err) }
} if !cfg.Tools.Exec.AllowRemote {
if !cfg.Tools.Exec.AllowRemote { t.Fatal("tools.exec.allow_remote should remain true when omitted from PUT /api/config")
t.Fatal("tools.exec.allow_remote should remain true when omitted from PUT /api/config") }
} }
}
func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) {
func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t)
configPath, cleanup := setupOAuthTestEnv(t) defer cleanup()
defer cleanup()
h := NewHandler(configPath)
h := NewHandler(configPath) mux := http.NewServeMux()
mux := http.NewServeMux() h.RegisterRoutes(mux)
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ "agents": {
"agents": { "defaults": {
"defaults": { "workspace": "~/.picoclaw/workspace"
"workspace": "~/.picoclaw/workspace" }
} },
}, "model_list": [
"model_list": [ {
{ "model_name": "custom-default",
"model_name": "custom-default", "model": "openai/gpt-4o",
"model": "openai/gpt-4o", "api_key": "sk-default"
"api_key": "sk-default" }
} ]
] }`))
}`)) req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
rec := httptest.NewRecorder() mux.ServeHTTP(rec, req)
mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK {
if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) }
}
cfg, err := config.LoadConfig(configPath)
cfg, err := config.LoadConfig(configPath) if err != nil {
if err != nil { t.Fatalf("LoadConfig() error = %v", err)
t.Fatalf("LoadConfig() error = %v", err) }
} if got := cfg.ModelList[0].APIBase; got != "" {
if got := cfg.ModelList[0].APIBase; got != "" { t.Fatalf("model_list[0].api_base = %q, want empty string", got)
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, #opsdev"
}
}
}`))
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)
}
}