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:
co-authored by
Claude Opus 4.6
parent
77ffea2d96
commit
b8beff8adf
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user