diff --git a/internal/im/mattermost_adapter.go b/internal/im/mattermost_adapter.go index 005c4f369..bf68bf59f 100644 --- a/internal/im/mattermost_adapter.go +++ b/internal/im/mattermost_adapter.go @@ -404,6 +404,7 @@ func (a *mattermostAdapter) handleWSEvent(ctx context.Context, event map[string] if !isFree && a.requireMention { hasMention := a.hasMention(messageText) if !hasMention { + debug.Log("mattermost", "adapter=%s drop: no mention of @%s in channel %s", a.name, a.botUsername, channelID) return } messageText = a.stripMention(messageText) @@ -469,12 +470,17 @@ func (a *mattermostAdapter) handleWSEvent(ctx context.Context, event map[string] func (a *mattermostAdapter) hasMention(text string) bool { lower := strings.ToLower(text) - // Word-boundary match: bot name "al" must not fire on "@alex" (#963). + // Boundary match: bot name "al" must not fire on "@alex" (#963). + // The boundary is username-charset-aware, not ASCII \b: Mattermost + // usernames allow ^[a-z0-9.\-_]+$ and may legally end in "-" or ".", + // where RE2 \b can never match (both sides non-word chars) and real + // mentions were silently dropped (#4322). A mention ends only when the + // next char is NOT a username char. for _, id := range []string{a.botUsername, a.botUserID} { if id == "" { continue } - re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(id) + `\b`) + re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(id) + `(?:$|[^a-z0-9.\-_])`) if re.MatchString(lower) { return true } @@ -483,14 +489,16 @@ func (a *mattermostAdapter) hasMention(text string) bool { } func (a *mattermostAdapter) stripMention(text string) string { - // Strip @bot_username and @bot_user_id mentions, with word boundary so a - // short bot name ("al") doesn't carve text out of "@alex" (#963). + // Strip @bot_username and @bot_user_id mentions, with a username-charset + // boundary so a short bot name ("al") doesn't carve text out of "@alex" + // (#963) while names ending in "-"/"." still strip (#4322). The boundary + // char (when present) is captured and preserved in the replacement. for _, pattern := range []string{a.botUsername, a.botUserID} { if pattern == "" { continue } - re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(pattern) + `\b`) - text = re.ReplaceAllString(text, "") + re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(pattern) + `(?:$|([^a-z0-9.\-_]))`) + text = re.ReplaceAllString(text, "$1") } return strings.TrimSpace(text) } diff --git a/internal/im/zz_mention_boundary_4322_test.go b/internal/im/zz_mention_boundary_4322_test.go new file mode 100644 index 000000000..72ea90b21 --- /dev/null +++ b/internal/im/zz_mention_boundary_4322_test.go @@ -0,0 +1,43 @@ +package im + +import "testing" + +// #4322 regression: Mattermost usernames may end in "-" or "." - the old +// ASCII \b boundary could never match there, silently dropping real mentions. +func TestMentionBoundaryUsernameCharsetName(t *testing.T) { + cases := []struct { + bot string + name string + text string + wantHit bool + }{ + {"dev-", "trailing dash", "@dev- help with logs", true}, + {"dev-", "trailing dash end-of-text", "ping @dev-", true}, + {"ops1.", "trailing dot", "@ops1. check logs", true}, + {"al", "plain name mention", "@al hello", true}, + {"al", "substring no false hit", "@alex hello", false}, + {"dev", "longer username no carve", "@dev-extra text", false}, + {"dev-", "no mention", "hello world", false}, + } + for _, c := range cases { + a := &mattermostAdapter{botUsername: c.bot} + if got := a.hasMention(c.text); got != c.wantHit { + t.Errorf("bot=%q case=%q hasMention(%q) = %v, want %v", c.bot, c.name, c.text, got, c.wantHit) + } + } +} + +func TestStripMentionBoundaryPreservesSeparator(t *testing.T) { + a := &mattermostAdapter{botUsername: "dev-"} + if got := a.stripMention("@dev- help with logs"); got != "help with logs" { + t.Errorf("strip = %q, want %q", got, "help with logs") + } + b := &mattermostAdapter{botUsername: "al"} + if got := b.stripMention("@al hello there"); got != "hello there" { + t.Errorf("strip = %q, want %q", got, "hello there") + } + // "@alex" must not be carved by short bot "al". + if got := b.stripMention("@alex hello"); got != "@alex hello" { + t.Errorf("strip = %q, want unchanged %q", got, "@alex hello") + } +}