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"`