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
This commit is contained in:
parent
6f5930624b
commit
40b7b6ee4b
10 changed files with 438 additions and 0 deletions
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"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/channels/dingtalk"
|
_ "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/discord"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,25 @@
|
||||||
"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",
|
||||||
|
"password": "",
|
||||||
|
"nickserv_password": "",
|
||||||
|
"sasl_user": "",
|
||||||
|
"sasl_password": "",
|
||||||
|
"channels": ["#mychannel"],
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"typing": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
|
||||||
1
go.mod
1
go.mod
|
|
@ -36,6 +36,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/h2non/filetype v1.1.3 // indirect
|
github.com/h2non/filetype v1.1.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
|
|
||||||
2
go.sum
2
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/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=
|
||||||
|
|
|
||||||
140
pkg/channels/irc/handler.go
Normal file
140
pkg/channels/irc/handler.go
Normal file
|
|
@ -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 == '_'
|
||||||
|
}
|
||||||
16
pkg/channels/irc/init.go
Normal file
16
pkg/channels/irc/init.go
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
181
pkg/channels/irc/irc.go
Normal file
181
pkg/channels/irc/irc.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,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.
|
||||||
|
|
@ -401,6 +402,22 @@ 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"`
|
||||||
|
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 {
|
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
|
||||||
|
|
|
||||||
56
scripts/test-irc.sh
Executable file
56
scripts/test-irc.sh
Executable file
|
|
@ -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"
|
||||||
Loading…
Add table
Reference in a new issue