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:
@@ -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,
|
||||
"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),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user