diff --git a/internal/channels/service.go b/internal/channels/service.go index 2b53aaa..b166627 100644 --- a/internal/channels/service.go +++ b/internal/channels/service.go @@ -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 diff --git a/internal/channels/service_test.go b/internal/channels/service_test.go index d65a066..193dcf7 100644 --- a/internal/channels/service_test.go +++ b/internal/channels/service_test.go @@ -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) { diff --git a/internal/messaging/mentions.go b/internal/messaging/mentions.go new file mode 100644 index 0000000..1c72335 --- /dev/null +++ b/internal/messaging/mentions.go @@ -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 +} diff --git a/internal/messaging/mentions_test.go b/internal/messaging/mentions_test.go new file mode 100644 index 0000000..76e0036 --- /dev/null +++ b/internal/messaging/mentions_test.go @@ -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) + } + }) + } +}