From 40b7b6ee4b7643c060c8a3dd71fc474d0027658c Mon Sep 17 00:00:00 2001 From: Amir Mamaghani Date: Thu, 5 Mar 2026 11:46:01 +0100 Subject: [PATCH 1/5] feat(channels): add IRC channel integration Add IRC as a new channel for picoclaw, supporting server connections, channel joins, DMs, mention-based group triggers, and IRCv3 typing indicators. Uses ergochat/irc-go for connection management with SASL, NickServ, and automatic reconnection support. Closes #1137 --- cmd/picoclaw/internal/gateway/helpers.go | 1 + config/config.example.json | 19 +++ go.mod | 1 + go.sum | 2 + pkg/channels/irc/handler.go | 140 ++++++++++++++++++ pkg/channels/irc/init.go | 16 ++ pkg/channels/irc/irc.go | 181 +++++++++++++++++++++++ pkg/channels/manager.go | 5 + pkg/config/config.go | 17 +++ scripts/test-irc.sh | 56 +++++++ 10 files changed, 438 insertions(+) create mode 100644 pkg/channels/irc/handler.go create mode 100644 pkg/channels/irc/init.go create mode 100644 pkg/channels/irc/irc.go create mode 100755 scripts/test-irc.sh diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 174f5db62..86110653d 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -14,6 +14,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" + _ "github.com/sipeed/picoclaw/pkg/channels/irc" _ "github.com/sipeed/picoclaw/pkg/channels/discord" _ "github.com/sipeed/picoclaw/pkg/channels/feishu" _ "github.com/sipeed/picoclaw/pkg/channels/line" diff --git a/config/config.example.json b/config/config.example.json index ef1bf3eda..bd20ac535 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -164,6 +164,25 @@ "max_steps": 10, "welcome_message": "Hello! I'm your AI assistant. How can I help you today?", "reasoning_channel_id": "" + }, + "irc": { + "enabled": false, + "server": "irc.libera.chat:6697", + "tls": true, + "nick": "mybot", + "password": "", + "nickserv_password": "", + "sasl_user": "", + "sasl_password": "", + "channels": ["#mychannel"], + "allow_from": [], + "group_trigger": { + "mention_only": true + }, + "typing": { + "enabled": false + }, + "reasoning_channel_id": "" } }, "providers": { diff --git a/go.mod b/go.mod index 238bd405c..0e8987bd7 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect + github.com/ergochat/irc-go v0.5.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/h2non/filetype v1.1.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 060594d06..be5d036a1 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= +github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw= +github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= diff --git a/pkg/channels/irc/handler.go b/pkg/channels/irc/handler.go new file mode 100644 index 000000000..ea9fbc85f --- /dev/null +++ b/pkg/channels/irc/handler.go @@ -0,0 +1,140 @@ +package irc + +import ( + "fmt" + "strings" + "time" + + "github.com/ergochat/irc-go/ircevent" + "github.com/ergochat/irc-go/ircmsg" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// onConnect is called after a successful connection (and on reconnect). +func (c *IRCChannel) onConnect(conn *ircevent.Connection) { + // NickServ auth (only if SASL is not configured) + if c.config.NickServPassword != "" && c.config.SASLUser == "" { + conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword) + } + + // Join configured channels + for _, ch := range c.config.Channels { + conn.Join(ch) + logger.InfoCF("irc", "Joined IRC channel", map[string]any{ + "channel": ch, + }) + } +} + +// onPrivmsg handles incoming PRIVMSG events. +func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) { + if len(e.Params) < 2 { + return + } + + nick := e.Nick() + currentNick := conn.CurrentNick() + + // Ignore own messages + if strings.EqualFold(nick, currentNick) { + return + } + + target := e.Params[0] // channel name or bot's nick + content := e.Params[1] // message text + + // Determine if this is a DM or channel message + isDM := !strings.HasPrefix(target, "#") && !strings.HasPrefix(target, "&") + + var chatID string + var peer bus.Peer + + if isDM { + chatID = nick + peer = bus.Peer{Kind: "direct", ID: nick} + } else { + chatID = target + peer = bus.Peer{Kind: "group", ID: target} + } + + sender := bus.SenderInfo{ + Platform: "irc", + PlatformID: nick, + CanonicalID: identity.BuildCanonicalID("irc", nick), + Username: nick, + DisplayName: nick, + } + + if !c.IsAllowedSender(sender) { + return + } + + // For channel messages, check group trigger (mention detection) + if !isDM { + isMentioned := isBotMentioned(content, currentNick) + if isMentioned { + content = stripBotMention(content, currentNick) + } + respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) + if !respond { + return + } + content = cleaned + } + + if strings.TrimSpace(content) == "" { + return + } + + messageID := fmt.Sprintf("%s-%d", nick, time.Now().UnixNano()) + + metadata := map[string]string{ + "platform": "irc", + "server": c.config.Server, + } + if !isDM { + metadata["channel"] = target + } + + c.HandleMessage(c.ctx, peer, messageID, nick, chatID, content, nil, metadata, sender) +} + +// isBotMentioned checks if the bot's nick appears in the message. +func isBotMentioned(content, botNick string) bool { + lower := strings.ToLower(content) + lowerNick := strings.ToLower(botNick) + + // "nick: " or "nick, " at start (most common IRC convention) + if strings.HasPrefix(lower, lowerNick+":") || strings.HasPrefix(lower, lowerNick+",") { + return true + } + + // Word-boundary match anywhere in the message + idx := strings.Index(lower, lowerNick) + if idx < 0 { + return false + } + before := idx == 0 || !isAlphanumeric(lower[idx-1]) + after := idx+len(lowerNick) >= len(lower) || !isAlphanumeric(lower[idx+len(lowerNick)]) + return before && after +} + +// stripBotMention removes "nick: " or "nick, " prefix from content. +func stripBotMention(content, botNick string) string { + lower := strings.ToLower(content) + lowerNick := strings.ToLower(botNick) + for _, sep := range []string{":", ","} { + prefix := lowerNick + sep + if strings.HasPrefix(lower, prefix) { + return strings.TrimSpace(content[len(prefix):]) + } + } + return content +} + +func isAlphanumeric(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' +} diff --git a/pkg/channels/irc/init.go b/pkg/channels/irc/init.go new file mode 100644 index 000000000..221d41b62 --- /dev/null +++ b/pkg/channels/irc/init.go @@ -0,0 +1,16 @@ +package irc + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("irc", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + if !cfg.Channels.IRC.Enabled { + return nil, nil + } + return NewIRCChannel(cfg.Channels.IRC, b) + }) +} diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go new file mode 100644 index 000000000..2d75f6aae --- /dev/null +++ b/pkg/channels/irc/irc.go @@ -0,0 +1,181 @@ +package irc + +import ( + "context" + "crypto/tls" + "fmt" + "strings" + + "github.com/ergochat/irc-go/ircevent" + "github.com/ergochat/irc-go/ircmsg" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// IRCChannel implements the Channel interface for IRC servers. +type IRCChannel struct { + *channels.BaseChannel + config config.IRCConfig + conn *ircevent.Connection + ctx context.Context + cancel context.CancelFunc +} + +// NewIRCChannel creates a new IRC channel. +func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) { + if cfg.Server == "" { + return nil, fmt.Errorf("irc server is required") + } + if cfg.Nick == "" { + return nil, fmt.Errorf("irc nick is required") + } + + base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(400), + channels.WithGroupTrigger(cfg.GroupTrigger), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), + ) + + return &IRCChannel{ + BaseChannel: base, + config: cfg, + }, nil +} + +// Start connects to the IRC server and begins listening. +func (c *IRCChannel) Start(ctx context.Context) error { + logger.InfoC("irc", "Starting IRC channel") + c.ctx, c.cancel = context.WithCancel(ctx) + + conn := &ircevent.Connection{ + Server: c.config.Server, + Nick: c.config.Nick, + User: c.config.Nick, + RealName: c.config.Nick, + Password: c.config.Password, + UseTLS: c.config.TLS, + RequestCaps: []string{"server-time", "message-tags"}, + QuitMessage: "Goodbye", + Debug: false, + Log: nil, + } + + if c.config.TLS { + conn.TLSConfig = &tls.Config{ + ServerName: extractHost(c.config.Server), + } + } + + // SASL auth (takes priority over NickServ) + if c.config.SASLUser != "" && c.config.SASLPassword != "" { + conn.SASLLogin = c.config.SASLUser + conn.SASLPassword = c.config.SASLPassword + } + + // Register event handlers + conn.AddConnectCallback(func(e ircmsg.Message) { + c.onConnect(conn) + }) + conn.AddCallback("PRIVMSG", func(e ircmsg.Message) { + c.onPrivmsg(conn, e) + }) + + if err := conn.Connect(); err != nil { + return fmt.Errorf("irc connect failed: %w", err) + } + + c.conn = conn + + // ircevent.Connection.Loop() handles reconnection internally. + go conn.Loop() + + c.SetRunning(true) + logger.InfoCF("irc", "IRC channel started", map[string]any{ + "server": c.config.Server, + "nick": c.config.Nick, + }) + return nil +} + +// Stop disconnects from the IRC server. +func (c *IRCChannel) Stop(ctx context.Context) error { + logger.InfoC("irc", "Stopping IRC channel") + c.SetRunning(false) + + if c.conn != nil { + c.conn.Quit() + } + if c.cancel != nil { + c.cancel() + } + + logger.InfoC("irc", "IRC channel stopped") + return nil +} + +// Send sends a message to an IRC channel or user. +func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + target := msg.ChatID + if target == "" { + return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + } + + if strings.TrimSpace(msg.Content) == "" { + return nil + } + + // Send each line separately (IRC is line-oriented) + lines := strings.Split(msg.Content, "\n") + for _, line := range lines { + line = strings.TrimRight(line, "\r") + if line == "" { + continue + } + c.conn.Privmsg(target, line) + } + + logger.DebugCF("irc", "Message sent", map[string]any{ + "target": target, + "lines": len(lines), + }) + return nil +} + +// StartTyping implements channels.TypingCapable using IRCv3 +typing client tag. +// Requires typing.enabled in config and server support for message-tags capability. +func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + noop := func() {} + + if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil { + return noop, nil + } + + // Check if server supports message-tags (required for TAGMSG) + if _, ok := c.conn.AcknowledgedCaps()["message-tags"]; !ok { + return noop, nil + } + + c.conn.SendWithTags(map[string]string{"+typing": "active"}, "TAGMSG", chatID) + + return func() { + if c.IsRunning() && c.conn != nil { + c.conn.SendWithTags(map[string]string{"+typing": "done"}, "TAGMSG", chatID) + } + }, nil +} + +// extractHost returns the hostname portion of a host:port string. +func extractHost(server string) string { + host, _, found := strings.Cut(server, ":") + if found { + return host + } + return server +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index fdd6d0c1f..2b1cf8e84 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -62,6 +62,7 @@ var channelRateConfig = map[string]float64{ "discord": 1, "slack": 1, "line": 10, + "irc": 2, } type channelWorker struct { @@ -267,6 +268,10 @@ func (m *Manager) initChannels() error { m.initChannel("pico", "Pico") } + if m.config.Channels.IRC.Enabled && m.config.Channels.IRC.Server != "" { + m.initChannel("irc", "IRC") + } + logger.InfoCF("channels", "Channel initialization completed", map[string]any{ "enabled_channels": len(m.channels), }) diff --git a/pkg/config/config.go b/pkg/config/config.go index 0ee3acfe0..00a69c28a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -218,6 +218,7 @@ type ChannelsConfig struct { WeComApp WeComAppConfig `json:"wecom_app"` WeComAIBot WeComAIBotConfig `json:"wecom_aibot"` Pico PicoConfig `json:"pico"` + IRC IRCConfig `json:"irc"` } // GroupTriggerConfig controls when the bot responds in group chats. @@ -401,6 +402,22 @@ type PicoConfig struct { Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } +type IRCConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"` + Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"` + TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"` + Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"` + Password string `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"` + NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"` + SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"` + SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"` + Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"` +} + type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 diff --git a/scripts/test-irc.sh b/scripts/test-irc.sh new file mode 100755 index 000000000..40db01756 --- /dev/null +++ b/scripts/test-irc.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# Starts a local Ergo IRC server for testing the IRC channel. +# +# Requirements: docker +# Usage: ./scripts/test-irc.sh + +set -e + +CONTAINER_NAME="picoclaw-test-ergo" +IRC_PORT=6667 + +# Clean up any previous instance +docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + +echo "Starting Ergo IRC server on port $IRC_PORT..." +docker run -d \ + --name "$CONTAINER_NAME" \ + -p "$IRC_PORT:6667" \ + ghcr.io/ergochat/ergo:stable + +for i in $(seq 1 10); do + if nc -z localhost "$IRC_PORT" 2>/dev/null; then + break + fi + if [ "$i" -eq 10 ]; then + echo "ERROR: Server did not start within 10s" + exit 1 + fi + sleep 1 +done + +echo "" +echo "IRC server ready on localhost:$IRC_PORT" +echo "" +echo "Add this to your ~/.picoclaw/config.json under \"channels\":" +echo "" +echo ' "irc": {' +echo ' "enabled": true,' +echo ' "server": "localhost:6667",' +echo ' "tls": false,' +echo ' "nick": "picobot",' +echo ' "channels": ["#test"],' +echo ' "allow_from": [],' +echo ' "group_trigger": { "mention_only": true }' +echo ' }' +echo "" +echo "Then run picoclaw:" +echo " cd packages/picoclaw && go run ./cmd/picoclaw gateway" +echo "" +echo "Connect with an IRC client:" +echo " irssi: /connect localhost $IRC_PORT" +echo " weechat: /server add test localhost/$IRC_PORT && /connect test" +echo " Join #test, then: picobot: hello" +echo "" +echo "To stop the IRC server:" +echo " docker rm -f $CONTAINER_NAME" From 1604582a411419812c1c9552081b588318443ff2 Mon Sep 17 00:00:00 2001 From: Amir Mamaghani Date: Thu, 5 Mar 2026 16:03:10 +0100 Subject: [PATCH 2/5] fix: resolve gci lint errors in IRC channel files Sort irc import alphabetically in helpers.go and fix struct field alignment in irc.go to satisfy golangci-lint gci formatter. Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/internal/gateway/helpers.go | 2 +- pkg/channels/irc/irc.go | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 86110653d..00b53e62c 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -14,9 +14,9 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" - _ "github.com/sipeed/picoclaw/pkg/channels/irc" _ "github.com/sipeed/picoclaw/pkg/channels/discord" _ "github.com/sipeed/picoclaw/pkg/channels/feishu" + _ "github.com/sipeed/picoclaw/pkg/channels/irc" _ "github.com/sipeed/picoclaw/pkg/channels/line" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/onebot" diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go index 2d75f6aae..b0c4874e1 100644 --- a/pkg/channels/irc/irc.go +++ b/pkg/channels/irc/irc.go @@ -51,16 +51,16 @@ func (c *IRCChannel) Start(ctx context.Context) error { c.ctx, c.cancel = context.WithCancel(ctx) conn := &ircevent.Connection{ - Server: c.config.Server, - Nick: c.config.Nick, - User: c.config.Nick, - RealName: c.config.Nick, - Password: c.config.Password, - UseTLS: c.config.TLS, - RequestCaps: []string{"server-time", "message-tags"}, - QuitMessage: "Goodbye", - Debug: false, - Log: nil, + Server: c.config.Server, + Nick: c.config.Nick, + User: c.config.Nick, + RealName: c.config.Nick, + Password: c.config.Password, + UseTLS: c.config.TLS, + RequestCaps: []string{"server-time", "message-tags"}, + QuitMessage: "Goodbye", + Debug: false, + Log: nil, } if c.config.TLS { From c10959b645dd70930e5791c5f10a8237cf443309 Mon Sep 17 00:00:00 2001 From: Amir Mamaghani Date: Thu, 5 Mar 2026 19:23:40 +0100 Subject: [PATCH 3/5] test(irc): add unit tests for IRC channel Test NewIRCChannel validation, extractHost, isBotMentioned, stripBotMention, and isAlphanumeric helper functions. --- pkg/channels/irc/irc_test.go | 134 +++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 pkg/channels/irc/irc_test.go diff --git a/pkg/channels/irc/irc_test.go b/pkg/channels/irc/irc_test.go new file mode 100644 index 000000000..dae3edb04 --- /dev/null +++ b/pkg/channels/irc/irc_test.go @@ -0,0 +1,134 @@ +package irc + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewIRCChannel(t *testing.T) { + msgBus := bus.NewMessageBus() + + t.Run("missing server", func(t *testing.T) { + cfg := config.IRCConfig{Nick: "bot"} + _, err := NewIRCChannel(cfg, msgBus) + if err == nil { + t.Error("expected error for missing server, got nil") + } + }) + + t.Run("missing nick", func(t *testing.T) { + cfg := config.IRCConfig{Server: "irc.example.com:6667"} + _, err := NewIRCChannel(cfg, msgBus) + if err == nil { + t.Error("expected error for missing nick, got nil") + } + }) + + t.Run("valid config", func(t *testing.T) { + cfg := config.IRCConfig{ + Server: "irc.example.com:6667", + Nick: "testbot", + Channels: []string{"#test"}, + } + ch, err := NewIRCChannel(cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ch.Name() != "irc" { + t.Errorf("Name() = %q, want %q", ch.Name(), "irc") + } + if ch.IsRunning() { + t.Error("new channel should not be running") + } + }) +} + +func TestExtractHost(t *testing.T) { + tests := []struct { + server string + want string + }{ + {"irc.libera.chat:6697", "irc.libera.chat"}, + {"localhost:6667", "localhost"}, + {"irc.example.com", "irc.example.com"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.server, func(t *testing.T) { + got := extractHost(tt.server) + if got != tt.want { + t.Errorf("extractHost(%q) = %q, want %q", tt.server, got, tt.want) + } + }) + } +} + +func TestIsBotMentioned(t *testing.T) { + tests := []struct { + name string + content string + nick string + want bool + }{ + {"colon prefix", "bot: hello", "bot", true}, + {"comma prefix", "bot, hello", "bot", true}, + {"case insensitive", "BOT: hello", "bot", true}, + {"word boundary mid", "hey bot what's up", "bot", true}, + {"no mention", "hello world", "bot", false}, + {"substring mismatch", "robotics are cool", "bot", false}, + {"nick at end", "hello bot", "bot", true}, + {"empty content", "", "bot", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isBotMentioned(tt.content, tt.nick) + if got != tt.want { + t.Errorf("isBotMentioned(%q, %q) = %v, want %v", tt.content, tt.nick, got, tt.want) + } + }) + } +} + +func TestStripBotMention(t *testing.T) { + tests := []struct { + name string + content string + nick string + want string + }{ + {"colon prefix", "bot: hello there", "bot", "hello there"}, + {"comma prefix", "bot, help me", "bot", "help me"}, + {"case insensitive", "BOT: hello", "bot", "hello"}, + {"no prefix match", "hello bot", "bot", "hello bot"}, + {"only prefix", "bot:", "bot", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripBotMention(tt.content, tt.nick) + if got != tt.want { + t.Errorf("stripBotMention(%q, %q) = %q, want %q", tt.content, tt.nick, got, tt.want) + } + }) + } +} + +func TestIsAlphanumeric(t *testing.T) { + alphanumeric := "azAZ09_" + for _, b := range []byte(alphanumeric) { + if !isAlphanumeric(b) { + t.Errorf("isAlphanumeric(%q) = false, want true", string(b)) + } + } + + nonAlpha := " !@#:," + for _, b := range []byte(nonAlpha) { + if isAlphanumeric(b) { + t.Errorf("isAlphanumeric(%q) = true, want false", string(b)) + } + } +} From a89ba06cb8453a6d4f0e8f01f40334a725a6bfe2 Mon Sep 17 00:00:00 2001 From: Amir Mamaghani Date: Fri, 6 Mar 2026 20:09:37 +0100 Subject: [PATCH 4/5] fix: address review feedback from @mengzhuo - Add separate User and RealName config fields (fall back to Nick) - Make RequestCaps configurable (defaults to server-time, message-tags) - Refactor isBotMentioned into nickMentionedAt returning position; stripBotMention now uses nickMentionedAt internally - Replace custom isAlphanumeric with unicode.IsLetter/unicode.IsDigit - Update tests for new nickMentionedAt function --- pkg/channels/irc/handler.go | 40 ++++++++++++++++++++++----------- pkg/channels/irc/irc.go | 19 +++++++++++++--- pkg/channels/irc/irc_test.go | 43 ++++++++++++++++++++++-------------- pkg/config/config.go | 3 +++ 4 files changed, 73 insertions(+), 32 deletions(-) diff --git a/pkg/channels/irc/handler.go b/pkg/channels/irc/handler.go index ea9fbc85f..aca4ddd11 100644 --- a/pkg/channels/irc/handler.go +++ b/pkg/channels/irc/handler.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" "time" + "unicode" "github.com/ergochat/irc-go/ircevent" "github.com/ergochat/irc-go/ircmsg" @@ -102,30 +103,47 @@ func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) { c.HandleMessage(c.ctx, peer, messageID, nick, chatID, content, nil, metadata, sender) } -// isBotMentioned checks if the bot's nick appears in the message. -func isBotMentioned(content, botNick string) bool { +// nickMentionedAt returns the byte index where botNick is mentioned in content +// with word-boundary checks, or -1 if not found. Also checks for "nick:" / +// "nick," prefix convention. +func nickMentionedAt(content, botNick string) int { lower := strings.ToLower(content) lowerNick := strings.ToLower(botNick) - // "nick: " or "nick, " at start (most common IRC convention) + // "nick:" or "nick," at start (most common IRC convention) if strings.HasPrefix(lower, lowerNick+":") || strings.HasPrefix(lower, lowerNick+",") { - return true + return 0 } // Word-boundary match anywhere in the message idx := strings.Index(lower, lowerNick) if idx < 0 { - return false + return -1 } - before := idx == 0 || !isAlphanumeric(lower[idx-1]) - after := idx+len(lowerNick) >= len(lower) || !isAlphanumeric(lower[idx+len(lowerNick)]) - return before && after + runes := []rune(lower) + nickRunes := []rune(lowerNick) + endIdx := idx + len(string(nickRunes)) + before := idx == 0 || !unicode.IsLetter(runes[idx-1]) && !unicode.IsDigit(runes[idx-1]) + after := endIdx >= len(lower) || !unicode.IsLetter(rune(lower[endIdx])) && !unicode.IsDigit(rune(lower[endIdx])) + if before && after { + return idx + } + return -1 +} + +// isBotMentioned checks if the bot's nick appears in the message. +func isBotMentioned(content, botNick string) bool { + return nickMentionedAt(content, botNick) >= 0 } // stripBotMention removes "nick: " or "nick, " prefix from content. func stripBotMention(content, botNick string) string { - lower := strings.ToLower(content) + idx := nickMentionedAt(content, botNick) + if idx != 0 { + return content + } lowerNick := strings.ToLower(botNick) + lower := strings.ToLower(content) for _, sep := range []string{":", ","} { prefix := lowerNick + sep if strings.HasPrefix(lower, prefix) { @@ -134,7 +152,3 @@ func stripBotMention(content, botNick string) string { } return content } - -func isAlphanumeric(b byte) bool { - return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' -} diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go index b0c4874e1..28c59b540 100644 --- a/pkg/channels/irc/irc.go +++ b/pkg/channels/irc/irc.go @@ -50,14 +50,27 @@ func (c *IRCChannel) Start(ctx context.Context) error { logger.InfoC("irc", "Starting IRC channel") c.ctx, c.cancel = context.WithCancel(ctx) + user := c.config.User + if user == "" { + user = c.config.Nick + } + realName := c.config.RealName + if realName == "" { + realName = c.config.Nick + } + caps := []string(c.config.RequestCaps) + if len(caps) == 0 { + caps = []string{"server-time", "message-tags"} + } + conn := &ircevent.Connection{ Server: c.config.Server, Nick: c.config.Nick, - User: c.config.Nick, - RealName: c.config.Nick, + User: user, + RealName: realName, Password: c.config.Password, UseTLS: c.config.TLS, - RequestCaps: []string{"server-time", "message-tags"}, + RequestCaps: caps, QuitMessage: "Goodbye", Debug: false, Log: nil, diff --git a/pkg/channels/irc/irc_test.go b/pkg/channels/irc/irc_test.go index dae3edb04..168252a4d 100644 --- a/pkg/channels/irc/irc_test.go +++ b/pkg/channels/irc/irc_test.go @@ -66,6 +66,33 @@ func TestExtractHost(t *testing.T) { } } +func TestNickMentionedAt(t *testing.T) { + tests := []struct { + name string + content string + nick string + want int + }{ + {"colon prefix", "bot: hello", "bot", 0}, + {"comma prefix", "bot, hello", "bot", 0}, + {"case insensitive", "BOT: hello", "bot", 0}, + {"word boundary mid", "hey bot what's up", "bot", 4}, + {"no mention", "hello world", "bot", -1}, + {"substring mismatch", "robotics are cool", "bot", -1}, + {"nick at end", "hello bot", "bot", 6}, + {"empty content", "", "bot", -1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := nickMentionedAt(tt.content, tt.nick) + if got != tt.want { + t.Errorf("nickMentionedAt(%q, %q) = %d, want %d", tt.content, tt.nick, got, tt.want) + } + }) + } +} + func TestIsBotMentioned(t *testing.T) { tests := []struct { name string @@ -116,19 +143,3 @@ func TestStripBotMention(t *testing.T) { }) } } - -func TestIsAlphanumeric(t *testing.T) { - alphanumeric := "azAZ09_" - for _, b := range []byte(alphanumeric) { - if !isAlphanumeric(b) { - t.Errorf("isAlphanumeric(%q) = false, want true", string(b)) - } - } - - nonAlpha := " !@#:," - for _, b := range []byte(nonAlpha) { - if isAlphanumeric(b) { - t.Errorf("isAlphanumeric(%q) = true, want false", string(b)) - } - } -} diff --git a/pkg/config/config.go b/pkg/config/config.go index 00a69c28a..a368e50ba 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -407,11 +407,14 @@ type IRCConfig struct { Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"` TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"` Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"` + User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"` + RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"` Password string `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"` NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"` SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"` SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"` Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"` + RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` From 94b6b656c24febedb742f6e543f735ccefb2ae6c Mon Sep 17 00:00:00 2001 From: Amir Mamaghani Date: Fri, 6 Mar 2026 20:21:53 +0100 Subject: [PATCH 5/5] docs: add user, real_name, and request_caps to IRC example config --- config/config.example.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/config.example.json b/config/config.example.json index bd20ac535..9779bb862 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -170,11 +170,14 @@ "server": "irc.libera.chat:6697", "tls": true, "nick": "mybot", + "user": "", + "real_name": "", "password": "", "nickserv_password": "", "sasl_user": "", "sasl_password": "", "channels": ["#mychannel"], + "request_caps": ["server-time", "message-tags"], "allow_from": [], "group_trigger": { "mention_only": true