fix(irc): normalize channel names from flexible config
This commit is contained in:
parent
ba83a5d256
commit
6bf87d19f4
5 changed files with 1535 additions and 1563 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2088
pkg/config/config.go
2088
pkg/config/config.go
File diff suppressed because it is too large
Load diff
|
|
@ -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, #ops,dev"`), &got); err != nil {
|
if err := json.Unmarshal([]byte(`"general, #ops,dev"`), &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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, #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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue