feat: @mentions in channel messages with inbox notifications

Parse @agentname patterns in channel message body. Mentioned agents
(who are channel members, excluding sender) get mention=true flag in
their inbox notification metadata. Channel message metadata includes
mentioned_agents list for all recipients.

- mentions.go: regex parser with email exclusion, dedup, 22 test cases
- BroadcastMessage: metadata now uses json.Marshal, includes mentions
- Tests verify mention flag, self-mention exclusion, non-member skip

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-03-14 15:15:53 +02:00
co-authored by Claude Opus 4.6
parent 77ffea2d96
commit b8beff8adf
4 changed files with 388 additions and 17 deletions
+61 -17
View File
@@ -2,6 +2,7 @@ package channels
import (
"context"
"encoding/json"
"fmt"
"log/slog"
@@ -394,6 +395,9 @@ func (s *Service) UpdateChannel(ctx context.Context, channelID int64, req Update
// BroadcastMessage sends a message to a channel. It creates a single channel
// message (visible in the channel timeline via GetChannelMessages) and also
// delivers individual DM notifications to each member's inbox.
// If the message body contains @mentions, mentioned members receive a
// "mention":true flag in their inbox notification metadata, and the channel
// message metadata includes "mentioned_agents".
func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAgent, body string, priority int, metadata string) ([]*messaging.Message, error) {
ch, err := s.store.GetChannel(ctx, channelID)
if err != nil {
@@ -409,41 +413,76 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
return nil, ErrNotChannelMember
}
// 1. Create the canonical channel message (no "to" — this is a channel post).
// This matches how the Web UI sends channel messages and is what
// GetChannelMessages queries for.
channelMeta := fmt.Sprintf(`{"channel_name":%q}`, ch.Name)
if metadata != "" {
channelMeta = fmt.Sprintf(`{"channel_name":%q,"user_metadata":%s}`, ch.Name, metadata)
// Get members for mentions and inbox notifications
members, err := s.store.GetMembers(ctx, channelID)
if err != nil {
return nil, fmt.Errorf("get members: %w", err)
}
// Parse @mentions from the message body
mentionedNames := messaging.ParseMentions(body)
memberSet := make(map[string]bool, len(members))
for _, m := range members {
memberSet[m.AgentName] = true
}
// Filter mentions to channel members only, exclude sender
mentionedMembers := make(map[string]bool)
var mentionedAgentsList []string
for _, name := range mentionedNames {
if name == fromAgent || !memberSet[name] {
continue
}
if !mentionedMembers[name] {
mentionedMembers[name] = true
mentionedAgentsList = append(mentionedAgentsList, name)
}
}
// 1. Create the canonical channel message (no "to" — this is a channel post).
channelMetaObj := map[string]any{"channel_name": ch.Name}
if metadata != "" {
channelMetaObj["user_metadata"] = json.RawMessage(metadata)
}
if len(mentionedAgentsList) > 0 {
channelMetaObj["mentioned_agents"] = mentionedAgentsList
}
channelMetaBytes, _ := json.Marshal(channelMetaObj)
channelMsg, err := s.msgService.SendMessage(ctx, fromAgent, "", body, messaging.SendOptions{
Subject: fmt.Sprintf("channel:%s", ch.Name),
Priority: priority,
Metadata: channelMeta,
Metadata: string(channelMetaBytes),
ChannelID: &channelID,
})
if err != nil {
return nil, fmt.Errorf("create channel message: %w", err)
}
// 2. Deliver inbox notifications to other members so they see it in read_inbox.
members, err := s.store.GetMembers(ctx, channelID)
if err != nil {
return nil, fmt.Errorf("get members: %w", err)
}
// 2. Deliver inbox notifications to other members.
recipientCount := 0
for _, m := range members {
if m.AgentName == fromAgent {
continue
}
inboxMeta := fmt.Sprintf(`{"channel_id":%d,"channel_name":%q,"channel_message_id":%d}`, channelID, ch.Name, channelMsg.ID)
inboxMetaObj := map[string]any{
"channel_id": channelID,
"channel_name": ch.Name,
"channel_message_id": channelMsg.ID,
}
if len(mentionedAgentsList) > 0 {
inboxMetaObj["mentioned_agents"] = mentionedAgentsList
}
if mentionedMembers[m.AgentName] {
inboxMetaObj["mention"] = true
}
inboxMetaBytes, _ := json.Marshal(inboxMetaObj)
_, err := s.msgService.SendMessage(ctx, fromAgent, m.AgentName, body, messaging.SendOptions{
Subject: fmt.Sprintf("channel:%s", ch.Name),
Priority: priority,
Metadata: inboxMeta,
Metadata: string(inboxMetaBytes),
})
if err != nil {
s.logger.Error("failed to send channel notification",
@@ -462,15 +501,20 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
"from", fromAgent,
"message_id", channelMsg.ID,
"recipients", recipientCount,
"mentions", len(mentionedAgentsList),
)
if s.tracer != nil {
s.tracer.Record(ctx, fromAgent, "channel.broadcast", map[string]any{
traceData := map[string]any{
"channel_id": channelID,
"channel_name": ch.Name,
"message_id": channelMsg.ID,
"recipients": recipientCount,
})
}
if len(mentionedAgentsList) > 0 {
traceData["mentioned_agents"] = mentionedAgentsList
}
s.tracer.Record(ctx, fromAgent, "channel.broadcast", traceData)
}
return []*messaging.Message{channelMsg}, nil
+154
View File
@@ -3,6 +3,7 @@ package channels
import (
"context"
"database/sql"
"encoding/json"
"errors"
"testing"
"time"
@@ -607,6 +608,159 @@ func TestService_BroadcastMessage(t *testing.T) {
}
// --- @mentions in BroadcastMessage tests ---
func TestService_BroadcastMessage_Mentions(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
ch, _ := svc.CreateChannel(ctx, CreateChannelRequest{Name: "mentions-test", Type: TypeStandard, CreatedBy: "agent-a"})
svc.JoinChannel(ctx, ch.ID, "agent-b")
svc.JoinChannel(ctx, ch.ID, "agent-c")
t.Run("mentioned member gets mention flag in inbox", func(t *testing.T) {
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @agent-b check this", 5, "")
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
// agent-b was mentioned — inbox notification should have mention:true
inbox, _ := svc.msgService.ReadInbox(ctx, "agent-b", messaging.ReadOptions{IncludeRead: true})
found := false
for _, m := range inbox {
if m.Body == "hey @agent-b check this" {
found = true
var meta map[string]any
json.Unmarshal(m.Metadata, &meta)
if meta["mention"] != true {
t.Error("agent-b inbox notification should have mention=true")
}
break
}
}
if !found {
t.Error("agent-b did not receive inbox notification")
}
// agent-c was NOT mentioned — inbox notification should NOT have mention:true
inbox, _ = svc.msgService.ReadInbox(ctx, "agent-c", messaging.ReadOptions{IncludeRead: true})
for _, m := range inbox {
if m.Body == "hey @agent-b check this" {
var meta map[string]any
json.Unmarshal(m.Metadata, &meta)
if meta["mention"] == true {
t.Error("agent-c should NOT have mention flag")
}
// But should still have mentioned_agents list
if _, ok := meta["mentioned_agents"]; !ok {
t.Error("agent-c metadata should have mentioned_agents list")
}
break
}
}
})
t.Run("channel message metadata includes mentioned_agents", func(t *testing.T) {
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "cc @agent-b and @agent-c", 5, "")
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
channelMsgs, _ := svc.msgService.GetChannelMessages(ctx, ch.ID, 10)
found := false
for _, m := range channelMsgs {
if m.Body == "cc @agent-b and @agent-c" {
found = true
var meta map[string]any
json.Unmarshal(m.Metadata, &meta)
mentioned, ok := meta["mentioned_agents"]
if !ok {
t.Error("channel message should have mentioned_agents")
} else {
list := mentioned.([]any)
if len(list) != 2 {
t.Errorf("mentioned_agents = %v, want 2 entries", list)
}
}
break
}
}
if !found {
t.Error("channel message not found")
}
})
t.Run("self-mention is excluded", func(t *testing.T) {
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "I am @agent-a and cc @agent-b", 5, "")
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
channelMsgs, _ := svc.msgService.GetChannelMessages(ctx, ch.ID, 10)
for _, m := range channelMsgs {
if m.Body == "I am @agent-a and cc @agent-b" {
var meta map[string]any
json.Unmarshal(m.Metadata, &meta)
mentioned := meta["mentioned_agents"].([]any)
for _, name := range mentioned {
if name == "agent-a" {
t.Error("sender should not be in mentioned_agents")
}
}
if len(mentioned) != 1 || mentioned[0] != "agent-b" {
t.Errorf("mentioned_agents = %v, want [agent-b]", mentioned)
}
break
}
}
})
t.Run("no mentions produces no mention metadata", func(t *testing.T) {
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "just a normal message", 5, "")
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
channelMsgs, _ := svc.msgService.GetChannelMessages(ctx, ch.ID, 10)
for _, m := range channelMsgs {
if m.Body == "just a normal message" {
var meta map[string]any
json.Unmarshal(m.Metadata, &meta)
if _, ok := meta["mentioned_agents"]; ok {
t.Error("should not have mentioned_agents when no mentions")
}
break
}
}
})
t.Run("non-member mention is ignored", func(t *testing.T) {
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider")
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @outsider and @agent-b", 5, "")
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
channelMsgs, _ := svc.msgService.GetChannelMessages(ctx, ch.ID, 10)
for _, m := range channelMsgs {
if m.Body == "hey @outsider and @agent-b" {
var meta map[string]any
json.Unmarshal(m.Metadata, &meta)
mentioned := meta["mentioned_agents"].([]any)
for _, name := range mentioned {
if name == "outsider" {
t.Error("non-member should not be in mentioned_agents")
}
}
if len(mentioned) != 1 || mentioned[0] != "agent-b" {
t.Errorf("mentioned_agents = %v, want [agent-b]", mentioned)
}
break
}
}
})
}
// --- Trace recording tests ---
func TestService_TracesRecorded(t *testing.T) {
+39
View File
@@ -0,0 +1,39 @@
package messaging
import (
"regexp"
"strings"
)
// mentionPattern matches @agentname patterns where agent names contain
// alphanumeric characters, hyphens, and underscores (max 64 chars).
// It uses a negative lookbehind-style approach: we exclude matches that
// look like email addresses (preceded by alphanumeric chars).
var mentionPattern = regexp.MustCompile(`(?:^|[^a-zA-Z0-9.@])@([a-zA-Z0-9][a-zA-Z0-9_-]{0,63})`)
// ParseMentions extracts unique @agentname mentions from message body text.
// It returns a deduplicated list of agent names (without the @ prefix).
//
// Rules:
// - Agent names start with an alphanumeric character
// - Agent names can contain: alphanumeric, hyphens, underscores (max 64 chars)
// - Email addresses (e.g., user@example.com) are NOT matched
// - Duplicate mentions are deduplicated
// - @ at end of string or @@ are ignored
func ParseMentions(body string) []string {
matches := mentionPattern.FindAllStringSubmatch(body, -1)
if len(matches) == 0 {
return nil
}
seen := make(map[string]bool, len(matches))
var result []string
for _, m := range matches {
name := strings.ToLower(m[1])
if !seen[name] {
seen[name] = true
result = append(result, name)
}
}
return result
}
+134
View File
@@ -0,0 +1,134 @@
package messaging
import (
"reflect"
"testing"
)
func TestParseMentions(t *testing.T) {
tests := []struct {
name string
body string
want []string
}{
{
name: "no mentions",
body: "hello world",
want: nil,
},
{
name: "single mention at start",
body: "@agent-a check this out",
want: []string{"agent-a"},
},
{
name: "single mention in middle",
body: "hey @agent-b can you help?",
want: []string{"agent-b"},
},
{
name: "single mention at end",
body: "please review @agent-c",
want: []string{"agent-c"},
},
{
name: "multiple different mentions",
body: "@agent-a and @agent-b should coordinate with @agent-c",
want: []string{"agent-a", "agent-b", "agent-c"},
},
{
name: "duplicate mentions deduplicated",
body: "@agent-a please tell @agent-a to check",
want: []string{"agent-a"},
},
{
name: "mention with underscore",
body: "cc @my_agent_1",
want: []string{"my_agent_1"},
},
{
name: "mention with hyphen",
body: "cc @my-agent-1",
want: []string{"my-agent-1"},
},
{
name: "bare @ at end of string",
body: "hello @",
want: nil,
},
{
name: "double @@",
body: "hello @@agent-a",
want: nil,
},
{
name: "email address not matched",
body: "send to user@example.com please",
want: nil,
},
{
name: "email-like with dot before @",
body: "contact john.doe@company.org for details",
want: nil,
},
{
name: "mention after newline",
body: "line one\n@agent-a check this",
want: []string{"agent-a"},
},
{
name: "mention after parenthesis",
body: "see (@agent-a) for details",
want: []string{"agent-a"},
},
{
name: "mention after comma",
body: "thanks,@agent-b",
want: []string{"agent-b"},
},
{
name: "case insensitive dedup",
body: "@Agent-A and @agent-a",
want: []string{"agent-a"},
},
{
name: "mention with numbers",
body: "hey @agent123",
want: []string{"agent123"},
},
{
name: "empty string",
body: "",
want: nil,
},
{
name: "only @",
body: "@",
want: nil,
},
{
name: "mention followed by punctuation",
body: "@agent-a, @agent-b! @agent-c.",
want: []string{"agent-a", "agent-b", "agent-c"},
},
{
name: "mention at start of line after space",
body: " @agent-a",
want: []string{"agent-a"},
},
{
name: "agent name starting with number",
body: "@1agent is here",
want: []string{"1agent"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseMentions(tt.body)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParseMentions(%q) = %v, want %v", tt.body, got, tt.want)
}
})
}
}