fix: address review feedback from @mengzhuo

- Add separate User and RealName config fields (fall back to Nick)
- Make RequestCaps configurable (defaults to server-time, message-tags)
- Refactor isBotMentioned into nickMentionedAt returning position;
  stripBotMention now uses nickMentionedAt internally
- Replace custom isAlphanumeric with unicode.IsLetter/unicode.IsDigit
- Update tests for new nickMentionedAt function
This commit is contained in:
Amir Mamaghani
2026-03-06 20:09:37 +01:00
parent c10959b645
commit a89ba06cb8
4 changed files with 73 additions and 32 deletions
+27 -13
View File
@@ -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 == '_'
}
+16 -3
View File
@@ -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,
+27 -16
View File
@@ -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))
}
}
}
+3
View File
@@ -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"`