Skip to content

Commit 2c8e9d3

Browse files
committed
Add deterministic mention syntax and harden fuzzy resolution
Introduce three deterministic mention paths that bypass fuzzy name matching, giving agents and pipelines unambiguous control: - [@name](mention:SGID) — zero API calls, caller-trusted SGID - [@name](person:ID) — one API call, resolves against pingable set - @sgid:VALUE — inline SGID embed for composability ResolveMentions is restructured into three sequential passes (anchor → @sgid → fuzzy @name), each producing <bc-attachment> tags that subsequent passes skip. Also hardens the existing fuzzy @name path with review fixes from #288: - ResolvePersonByName resolves against pingable set only - Expanded prefix regex to match after (, [, ", ' - Trailing-character bailout for hyphens and word-internal apostrophes - Case-insensitive isInsideBcAttachment guard - Chat auto-promotes text/plain → text/html when mentions resolve - Fix double-escaping of HTML entities in mention: display text
1 parent aef1e21 commit 2c8e9d3

6 files changed

Lines changed: 585 additions & 56 deletions

File tree

internal/commands/chat_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,108 @@ func TestChatPostViaSubcommandWithChatFlag(t *testing.T) {
671671
"content should be passed through subcommand path")
672672
}
673673

674+
// mockChatMentionTransport handles resolver API calls for mentions and captures POST body.
675+
type mockChatMentionTransport struct {
676+
capturedBody []byte
677+
}
678+
679+
func (t *mockChatMentionTransport) RoundTrip(req *http.Request) (*http.Response, error) {
680+
header := make(http.Header)
681+
header.Set("Content-Type", "application/json")
682+
683+
if req.Method == "GET" {
684+
var body string
685+
switch {
686+
case strings.Contains(req.URL.Path, "/projects.json"):
687+
body = `[{"id": 123, "name": "Test Project"}]`
688+
case strings.Contains(req.URL.Path, "/projects/"):
689+
body = `{"id": 123, "dock": [{"name": "chat", "id": 789, "enabled": true}]}`
690+
case strings.Contains(req.URL.Path, "/circles/people.json") || strings.Contains(req.URL.Path, "/people/pingable.json"):
691+
body = `[{"id": 42000, "name": "Jane Smith", "email_address": "jane@example.com", "attachable_sgid": "sgid-jane"}]`
692+
default:
693+
body = `{}`
694+
}
695+
return &http.Response{
696+
StatusCode: 200,
697+
Body: io.NopCloser(strings.NewReader(body)),
698+
Header: header,
699+
}, nil
700+
}
701+
702+
if req.Method == "POST" {
703+
if req.Body != nil {
704+
body, _ := io.ReadAll(req.Body)
705+
t.capturedBody = body
706+
req.Body.Close()
707+
}
708+
mockResp := `{"id": 999, "content": "Test", "created_at": "2024-01-01T00:00:00Z"}`
709+
return &http.Response{
710+
StatusCode: 201,
711+
Body: io.NopCloser(strings.NewReader(mockResp)),
712+
Header: header,
713+
}, nil
714+
}
715+
716+
return nil, errors.New("unexpected request")
717+
}
718+
719+
// TestChatPostMentionPromotesToHTML verifies that a chat post with @Name
720+
// auto-promotes content type to text/html when mentions are resolved.
721+
func TestChatPostMentionPromotesToHTML(t *testing.T) {
722+
t.Setenv("BASECAMP_NO_KEYRING", "1")
723+
724+
transport := &mockChatMentionTransport{}
725+
app, _ := newTestAppWithTransport(t, transport)
726+
727+
cmd := NewChatCmd()
728+
err := executeChatCommand(cmd, app, "post", "Hey @Jane.Smith, check this")
729+
require.NoError(t, err)
730+
require.NotEmpty(t, transport.capturedBody)
731+
732+
var requestBody map[string]any
733+
err = json.Unmarshal(transport.capturedBody, &requestBody)
734+
require.NoError(t, err)
735+
736+
// Content type should be promoted to text/html when mentions are present
737+
assert.Equal(t, "text/html", requestBody["content_type"],
738+
"content_type should be promoted to text/html when mentions are resolved")
739+
740+
content, ok := requestBody["content"].(string)
741+
require.True(t, ok)
742+
assert.Contains(t, content, "bc-attachment",
743+
"content should contain bc-attachment mention tag")
744+
}
745+
746+
// TestChatPostPlainTextOptOut verifies that --content-type text/plain
747+
// bypasses mention resolution and sends content as-is.
748+
func TestChatPostPlainTextOptOut(t *testing.T) {
749+
t.Setenv("BASECAMP_NO_KEYRING", "1")
750+
751+
transport := &mockChatMentionTransport{}
752+
app, _ := newTestAppWithTransport(t, transport)
753+
754+
cmd := NewChatCmd()
755+
err := executeChatCommand(cmd, app, "post", "Hey @Jane.Smith", "--content-type", "text/plain")
756+
require.NoError(t, err)
757+
require.NotEmpty(t, transport.capturedBody)
758+
759+
var requestBody map[string]any
760+
err = json.Unmarshal(transport.capturedBody, &requestBody)
761+
require.NoError(t, err)
762+
763+
// Content type should remain text/plain
764+
assert.Equal(t, "text/plain", requestBody["content_type"],
765+
"content_type should remain text/plain when explicitly set")
766+
767+
content, ok := requestBody["content"].(string)
768+
require.True(t, ok)
769+
// Mentions should NOT be resolved — raw text preserved
770+
assert.NotContains(t, content, "bc-attachment",
771+
"content should not contain bc-attachment when content-type is text/plain")
772+
assert.Contains(t, content, "@Jane.Smith",
773+
"@mention should be left as literal text")
774+
}
775+
674776
// TestChatDeleteReturnsDeletedPayload verifies that delete returns {"deleted": true, "id": "..."}.
675777
func TestChatDeleteReturnsDeletedPayload(t *testing.T) {
676778
t.Setenv("BASECAMP_NO_KEYRING", "1")

internal/commands/helpers.go

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -470,18 +470,39 @@ func applySubscribeFlags(ctx context.Context, resolver *names.Resolver, subscrib
470470
return nil, nil
471471
}
472472

473-
// resolveMentions scans HTML for @Name and @First.Last mentions and replaces
474-
// them with Basecamp mention attachment tags. Silently returns unchanged HTML
475-
// if no mentions are found. Errors if a mentioned name cannot be resolved.
473+
// resolveMentions scans HTML for mention syntax and replaces matches with
474+
// Basecamp mention attachment tags. Supports three syntaxes:
475+
// - [@Name](mention:SGID) — zero API calls (SGID embedded directly)
476+
// - [@Name](person:ID) — one API call (ID→SGID via pingable set)
477+
// - @Name / @First.Last — fuzzy name resolution via pingable set
478+
//
479+
// Also supports @sgid:VALUE inline syntax for pipeline composability.
480+
// Silently returns unchanged HTML if no mentions are found.
476481
func resolveMentions(ctx context.Context, resolver *names.Resolver, html string) (string, error) {
477-
return richtext.ResolveMentions(html, func(name string) (string, string, error) {
478-
person, err := resolver.ResolvePersonByName(ctx, name)
479-
if err != nil {
480-
return "", "", err
481-
}
482-
if person.AttachableSGID == "" {
483-
return "", "", fmt.Errorf("person %q has no attachable SGID", person.Name)
484-
}
485-
return person.AttachableSGID, person.Name, nil
486-
})
482+
return richtext.ResolveMentions(html,
483+
func(name string) (string, string, error) {
484+
person, err := resolver.ResolvePersonByName(ctx, name)
485+
if err != nil {
486+
return "", "", err
487+
}
488+
if person.AttachableSGID == "" {
489+
return "", "", fmt.Errorf("person %q has no attachable SGID", person.Name)
490+
}
491+
return person.AttachableSGID, person.Name, nil
492+
},
493+
func(id string) (string, string, error) {
494+
personID, err := strconv.ParseInt(id, 10, 64)
495+
if err != nil {
496+
return "", "", output.ErrUsage(fmt.Sprintf("invalid person ID %q — must be numeric", id))
497+
}
498+
person, err := resolver.ResolvePersonByID(ctx, personID)
499+
if err != nil {
500+
return "", "", err
501+
}
502+
if person.AttachableSGID == "" {
503+
return "", "", fmt.Errorf("person %q has no attachable SGID", person.Name)
504+
}
505+
return person.AttachableSGID, person.Name, nil
506+
},
507+
)
487508
}

internal/names/resolver.go

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -248,16 +248,14 @@ func (r *Resolver) ResolvePerson(ctx context.Context, input string) (string, str
248248
}
249249

250250
// ResolvePersonByName resolves a person name to a full Person record including AttachableSGID.
251-
// Uses the same resolution logic as ResolvePerson (exact > case-insensitive > partial > pingable).
251+
// Resolves against the pingable set only — mentions require the target to be pingable.
252252
func (r *Resolver) ResolvePersonByName(ctx context.Context, input string) (*Person, error) {
253-
// Fetch people for name resolution
254-
people, err := r.getPeople(ctx)
253+
pingable, err := r.getPingable(ctx)
255254
if err != nil {
256255
return nil, err
257256
}
258257

259-
// Try name resolution
260-
match, matches := resolve(input, people, func(p Person) (int64, string) {
258+
match, matches := resolve(input, pingable, func(p Person) (int64, string) {
261259
return p.ID, p.Name
262260
})
263261

@@ -273,33 +271,31 @@ func (r *Resolver) ResolvePersonByName(ctx context.Context, input string) (*Pers
273271
return nil, output.ErrAmbiguous("person", names)
274272
}
275273

276-
// Fallback: try pingable people
277-
pingable, _ := r.getPingable(ctx)
278-
if len(pingable) > 0 {
279-
pingMatch, pingMatches := resolve(input, pingable, func(p Person) (int64, string) {
280-
return p.ID, p.Name
281-
})
282-
if pingMatch != nil {
283-
return pingMatch, nil
284-
}
285-
if len(pingMatches) > 1 {
286-
pingNames := make([]string, len(pingMatches))
287-
for i, m := range pingMatches {
288-
pingNames[i] = m.Name
289-
}
290-
return nil, output.ErrAmbiguous("person", pingNames)
291-
}
292-
}
293-
294-
// Not found
295-
allPeople := deduplicatePeople(people, pingable)
296-
suggestions := suggest(input, allPeople, func(p Person) string { return p.Name })
274+
suggestions := suggest(input, pingable, func(p Person) string { return p.Name })
297275
if len(suggestions) > 0 {
298276
return nil, output.ErrNotFoundHint("Person", input, "Did you mean: "+strings.Join(suggestions, ", "))
299277
}
300278
return nil, output.ErrNotFound("Person", input)
301279
}
302280

281+
// ResolvePersonByID looks up a person by numeric ID in the pingable set.
282+
// Returns the Person record including AttachableSGID.
283+
// Returns not-found if the person is not in the pingable set.
284+
func (r *Resolver) ResolvePersonByID(ctx context.Context, id int64) (*Person, error) {
285+
pingable, err := r.getPingable(ctx)
286+
if err != nil {
287+
return nil, err
288+
}
289+
290+
for i := range pingable {
291+
if pingable[i].ID == id {
292+
return &pingable[i], nil
293+
}
294+
}
295+
296+
return nil, output.ErrNotFound("Person", strconv.FormatInt(id, 10))
297+
}
298+
303299
// deduplicatePeople merges two person lists, removing duplicates by ID.
304300
func deduplicatePeople(people, pingable []Person) []Person {
305301
seen := make(map[int64]struct{}, len(people))

internal/names/resolver_test.go

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,12 +1011,12 @@ func TestGetTodolistsPartialFetchFailsHard(t *testing.T) {
10111011

10121012
func TestResolvePersonByName(t *testing.T) {
10131013
r := newMockResolver()
1014-
r.setPeople([]Person{
1014+
// ResolvePersonByName resolves against pingable set only
1015+
r.setPingable([]Person{
10151016
{ID: 1, AttachableSGID: "sgid-john", Name: "John Doe", Email: "john@example.com"},
10161017
{ID: 2, AttachableSGID: "sgid-jane", Name: "Jane Smith", Email: "jane@example.com"},
10171018
{ID: 3, AttachableSGID: "sgid-igor", Name: "Igor Logachev", Email: "igor@example.com"},
10181019
})
1019-
r.setPingable([]Person{}) // empty pingable cache to avoid SDK call
10201020

10211021
ctx := context.Background()
10221022

@@ -1048,8 +1048,9 @@ func TestResolvePersonByName(t *testing.T) {
10481048
}
10491049
}
10501050

1051-
func TestResolvePersonByNamePingableFallback(t *testing.T) {
1051+
func TestResolvePersonByNamePingableOnly(t *testing.T) {
10521052
r := newMockResolver()
1053+
// Person exists in people list but NOT in pingable — should not be found
10531054
r.setPeople([]Person{
10541055
{ID: 1, AttachableSGID: "sgid-john", Name: "John Doe"},
10551056
})
@@ -1059,9 +1060,43 @@ func TestResolvePersonByNamePingableFallback(t *testing.T) {
10591060

10601061
ctx := context.Background()
10611062

1062-
// Should find in pingable
1063-
person, err := r.ResolvePersonByName(ctx, "External")
1063+
// Person in people but not pingable → not found
1064+
_, err := r.ResolvePersonByName(ctx, "John Doe")
1065+
require.Error(t, err)
1066+
1067+
var outErr *output.Error
1068+
require.True(t, errors.As(err, &outErr))
1069+
assert.Equal(t, output.CodeNotFound, outErr.Code)
1070+
1071+
// Person in pingable → found
1072+
person, err := r.ResolvePersonByName(ctx, "External Client")
10641073
require.NoError(t, err)
10651074
assert.Equal(t, "External Client", person.Name)
10661075
assert.Equal(t, "sgid-client", person.AttachableSGID)
10671076
}
1077+
1078+
func TestResolvePersonByID(t *testing.T) {
1079+
r := newMockResolver()
1080+
r.setPingable([]Person{
1081+
{ID: 42000, AttachableSGID: "sgid-jane", Name: "Jane Smith"},
1082+
{ID: 42001, AttachableSGID: "sgid-bob", Name: "Bob Jones"},
1083+
})
1084+
1085+
ctx := context.Background()
1086+
1087+
t.Run("found", func(t *testing.T) {
1088+
person, err := r.ResolvePersonByID(ctx, 42000)
1089+
require.NoError(t, err)
1090+
assert.Equal(t, "Jane Smith", person.Name)
1091+
assert.Equal(t, "sgid-jane", person.AttachableSGID)
1092+
})
1093+
1094+
t.Run("not found", func(t *testing.T) {
1095+
_, err := r.ResolvePersonByID(ctx, 99999)
1096+
require.Error(t, err)
1097+
1098+
var outErr *output.Error
1099+
require.True(t, errors.As(err, &outErr))
1100+
assert.Equal(t, output.CodeNotFound, outErr.Code)
1101+
})
1102+
}

0 commit comments

Comments
 (0)