mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Merge pull request #1138 from amirmamaghani/feat/irc-channel
feat(channels): add IRC channel integration
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
_ "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/line"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
||||||
|
|||||||
@@ -164,6 +164,28 @@
|
|||||||
"max_steps": 10,
|
"max_steps": 10,
|
||||||
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
|
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
|
},
|
||||||
|
"irc": {
|
||||||
|
"enabled": false,
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"typing": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // 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/gdamore/encoding v1.0.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
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.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package irc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if strings.HasPrefix(lower, lowerNick+":") || strings.HasPrefix(lower, lowerNick+",") {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word-boundary match anywhere in the message
|
||||||
|
idx := strings.Index(lower, lowerNick)
|
||||||
|
if idx < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
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) {
|
||||||
|
return strings.TrimSpace(content[len(prefix):])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
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: user,
|
||||||
|
RealName: realName,
|
||||||
|
Password: c.config.Password,
|
||||||
|
UseTLS: c.config.TLS,
|
||||||
|
RequestCaps: caps,
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
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 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
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ var channelRateConfig = map[string]float64{
|
|||||||
"discord": 1,
|
"discord": 1,
|
||||||
"slack": 1,
|
"slack": 1,
|
||||||
"line": 10,
|
"line": 10,
|
||||||
|
"irc": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelWorker struct {
|
type channelWorker struct {
|
||||||
@@ -267,6 +268,10 @@ func (m *Manager) initChannels() error {
|
|||||||
m.initChannel("pico", "Pico")
|
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{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ type ChannelsConfig struct {
|
|||||||
WeComApp WeComAppConfig `json:"wecom_app"`
|
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||||
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
||||||
Pico PicoConfig `json:"pico"`
|
Pico PicoConfig `json:"pico"`
|
||||||
|
IRC IRCConfig `json:"irc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupTriggerConfig controls when the bot responds in group chats.
|
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||||
@@ -414,6 +415,25 @@ type PicoConfig struct {
|
|||||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
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"`
|
||||||
|
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"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
type HeartbeatConfig struct {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||||
|
|||||||
Executable
+56
@@ -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"
|
||||||
Reference in New Issue
Block a user