From 40b7b6ee4b7643c060c8a3dd71fc474d0027658c Mon Sep 17 00:00:00 2001 From: Amir Mamaghani Date: Thu, 5 Mar 2026 11:46:01 +0100 Subject: [PATCH] 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"