Skip to content

Commit 5716166

Browse files
waynemsmithclaude
andcommitted
Add @mention resolution for comments and card descriptions
Port of the fork's @mention patch onto the v4 (fizzy-sdk) base. @firstname in a comment body or card description is resolved to an ActionText mention attachment so Fizzy delivers a notification, instead of being posted as plain text. - internal/commands/mentions.go: resolveMentions scans text for @name (Unicode-aware, skips emails and markdown code spans/blocks), looks the user up by first name via the mentionables list, and substitutes the <action-text-attachment content-type="application/vnd.actiontext.mention"> markup. Ambiguous/unknown names warn to stderr and are left as text. - internal/client: add GetHTML (Accept: text/html) used to fetch the mentionables list. - inline_attachments.go: resolveRichTextContent now resolves mentions before markdown->HTML conversion; the four comment/card create+update call sites pass getClient(). Note: the mentionables fetch path (/prompts/users) is not yet verified against the v4 token auth model — runtime auth wiring is the next step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6a3ae59 commit 5716166

9 files changed

Lines changed: 650 additions & 7 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Build artifacts
22
bin/
33
/fizzy
4+
/fizzy-fork
45
*.exe
56
*.exe~
67
*.dll

internal/client/client.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,49 @@ func (c *Client) Delete(path string) (*APIResponse, error) {
170170
return c.request("DELETE", path, nil)
171171
}
172172

173+
// GetHTML performs a GET request expecting an HTML response.
174+
// Unlike Get, it sets Accept: text/html and does not attempt JSON parsing.
175+
func (c *Client) GetHTML(path string) (*APIResponse, error) {
176+
requestURL := c.buildURL(path)
177+
req, err := http.NewRequestWithContext(context.Background(), "GET", requestURL, nil)
178+
if err != nil {
179+
return nil, errors.NewNetworkError(fmt.Sprintf("Failed to create request: %v", err))
180+
}
181+
182+
c.setHeaders(req)
183+
req.Header.Set("Accept", "text/html")
184+
185+
if c.Verbose {
186+
fmt.Fprintf(os.Stderr, "> GET %s (HTML)\n", requestURL)
187+
}
188+
189+
resp, err := c.doWithRetry(req)
190+
if err != nil {
191+
return nil, errors.NewNetworkError(fmt.Sprintf("Request failed: %v", err))
192+
}
193+
defer func() { _ = resp.Body.Close() }()
194+
195+
respBody, err := io.ReadAll(resp.Body)
196+
if err != nil {
197+
return nil, errors.NewNetworkError(fmt.Sprintf("Failed to read response: %v", err))
198+
}
199+
200+
if c.Verbose {
201+
fmt.Fprintf(os.Stderr, "< %d %s\n", resp.StatusCode, http.StatusText(resp.StatusCode))
202+
}
203+
204+
apiResp := &APIResponse{
205+
StatusCode: resp.StatusCode,
206+
Body: respBody,
207+
}
208+
209+
if resp.StatusCode >= 400 {
210+
return apiResp, c.errorFromResponse(resp.StatusCode, respBody, resp.Header)
211+
}
212+
213+
return apiResp, nil
214+
}
215+
173216
func (c *Client) request(method, path string, body any) (*APIResponse, error) {
174217
requestURL := c.buildURL(path)
175218

internal/client/interface.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type API interface {
1313
FollowLocation(location string) (*APIResponse, error)
1414
UploadFile(filePath string) (*APIResponse, error)
1515
DownloadFile(urlPath string, destPath string) error
16+
GetHTML(path string) (*APIResponse, error)
1617
}
1718

1819
// Ensure Client implements API interface

internal/commands/card.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ var cardCreateCmd = &cobra.Command{
240240
return newRequiredFlagError("title")
241241
}
242242

243-
description, err := resolveRichTextContent(cardCreateDescription, cardCreateDescriptionFile)
243+
description, err := resolveRichTextContent(cardCreateDescription, cardCreateDescriptionFile, getClient())
244244
if err != nil {
245245
return err
246246
}
@@ -330,7 +330,7 @@ var cardUpdateCmd = &cobra.Command{
330330
cardNumber := args[0]
331331

332332
hasDescriptionInput := cardUpdateDescription != "" || cardUpdateDescriptionFile != ""
333-
description, err := resolveRichTextContent(cardUpdateDescription, cardUpdateDescriptionFile)
333+
description, err := resolveRichTextContent(cardUpdateDescription, cardUpdateDescriptionFile, getClient())
334334
if err != nil {
335335
return err
336336
}

internal/commands/comment.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ var commentCreateCmd = &cobra.Command{
142142
return newRequiredFlagError("card")
143143
}
144144

145-
body, err := resolveRichTextContent(commentCreateBody, commentCreateBodyFile)
145+
body, err := resolveRichTextContent(commentCreateBody, commentCreateBodyFile, getClient())
146146
if err != nil {
147147
return err
148148
}
@@ -206,7 +206,7 @@ var commentUpdateCmd = &cobra.Command{
206206
cardNumber := commentUpdateCard
207207

208208
hasBodyInput := commentUpdateBody != "" || commentUpdateBodyFile != ""
209-
body, err := resolveRichTextContent(commentUpdateBody, commentUpdateBodyFile)
209+
body, err := resolveRichTextContent(commentUpdateBody, commentUpdateBodyFile, getClient())
210210
if err != nil {
211211
return err
212212
}

internal/commands/inline_attachments.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,26 @@ import (
66
"os"
77
"strings"
88

9+
"github.com/basecamp/fizzy-cli/internal/client"
910
"github.com/basecamp/fizzy-cli/internal/errors"
1011
)
1112

12-
func resolveRichTextContent(content string, filePath string) (string, error) {
13+
// resolveRichTextContent reads body content (inline or from a file), resolves
14+
// @Name mentions to ActionText mention attachments using c, then converts the
15+
// result from markdown to HTML. Mentions are resolved before HTML conversion so
16+
// the inserted attachment markup passes through goldmark unchanged.
17+
func resolveRichTextContent(content string, filePath string, c client.API) (string, error) {
1318
if filePath != "" {
1419
fileContent, err := os.ReadFile(filePath)
1520
if err != nil {
1621
return "", err
1722
}
18-
return markdownToHTML(string(fileContent)), nil
23+
return markdownToHTML(resolveMentions(string(fileContent), c)), nil
1924
}
2025
if content == "" {
2126
return "", nil
2227
}
23-
return markdownToHTML(content), nil
28+
return markdownToHTML(resolveMentions(content, c)), nil
2429
}
2530

2631
func appendInlineAttachmentsToContent(content string, paths []string) (string, error) {

internal/commands/mentions.go

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"html"
6+
"os"
7+
"regexp"
8+
"strings"
9+
"sync"
10+
"unicode"
11+
12+
"github.com/basecamp/fizzy-cli/internal/client"
13+
)
14+
15+
// mentionUser represents a mentionable user parsed from the /prompts/users endpoint.
16+
type mentionUser struct {
17+
FirstName string // e.g. "Wayne"
18+
FullName string // e.g. "Wayne Smith"
19+
SGID string // signed global ID for ActionText
20+
AvatarSrc string // e.g. "/6103476/users/03f5awg7.../avatar"
21+
}
22+
23+
// Package-level cache: populated once per CLI invocation.
24+
var (
25+
mentionUsers []mentionUser
26+
mentionOnce sync.Once
27+
mentionErr error
28+
)
29+
30+
// resetMentionCache resets the cache for testing.
31+
func resetMentionCache() {
32+
mentionOnce = sync.Once{}
33+
mentionUsers = nil
34+
mentionErr = nil
35+
}
36+
37+
// mentionRegex matches @Name patterns not preceded by word characters or dots
38+
// (to avoid matching emails like user@example.com).
39+
// Supports Unicode letters and hyphens in names (e.g. @José, @Mary-Jane).
40+
var mentionRegex = regexp.MustCompile(`(?:^|[^-\p{L}\p{N}_.])@([\p{L}][\p{L}\p{N}_-]*)`)
41+
42+
// promptItemRegex matches opening <lexxy-prompt-item> tags.
43+
// Attributes are extracted separately to handle any order.
44+
var promptItemRegex = regexp.MustCompile(`<lexxy-prompt-item\s[^>]*>`)
45+
46+
// searchAttrRegex extracts the search attribute value.
47+
var searchAttrRegex = regexp.MustCompile(`\ssearch="([^"]+)"`)
48+
49+
// sgidAttrRegex extracts the sgid attribute value.
50+
var sgidAttrRegex = regexp.MustCompile(`\ssgid="([^"]+)"`)
51+
52+
// promptItemEndRegex matches the closing tag for a prompt item block.
53+
var promptItemEndRegex = regexp.MustCompile(`</lexxy-prompt-item>`)
54+
55+
// avatarRegex extracts the src attribute from the first <img> tag.
56+
var avatarRegex = regexp.MustCompile(`<img[^>]+src="([^"]+)"`)
57+
58+
// codeBlockRegex matches fenced code blocks (``` ... ```).
59+
var codeBlockRegex = regexp.MustCompile("(?s)```.*?```")
60+
61+
// codeSpanRegex matches inline code spans (` ... `).
62+
var codeSpanRegex = regexp.MustCompile("`[^`]+`")
63+
64+
// resolveMentions scans text for @FirstName patterns and replaces them with
65+
// ActionText mention HTML. If the text contains no @ characters, it is returned
66+
// unchanged. On any error fetching users, the original text is returned with a
67+
// warning printed to stderr.
68+
//
69+
// Mentions inside markdown code spans (`@name`) and fenced code blocks are not
70+
// resolved, preserving the user's intended literal text.
71+
func resolveMentions(text string, c client.API) string {
72+
if !strings.Contains(text, "@") {
73+
return text
74+
}
75+
76+
mentionOnce.Do(func() {
77+
mentionUsers, mentionErr = fetchMentionUsers(c)
78+
})
79+
80+
if mentionErr != nil {
81+
fmt.Fprintf(os.Stderr, "Warning: could not fetch mentionable users: %v\n", mentionErr)
82+
return text
83+
}
84+
85+
if len(mentionUsers) == 0 {
86+
return text
87+
}
88+
89+
// Protect code blocks and code spans from mention resolution by replacing
90+
// them with placeholders, resolving mentions, then restoring the originals.
91+
var codeChunks []string
92+
placeholder := func(s string) string {
93+
idx := len(codeChunks)
94+
codeChunks = append(codeChunks, s)
95+
return fmt.Sprintf("\x00CODE%d\x00", idx)
96+
}
97+
98+
protected := codeBlockRegex.ReplaceAllStringFunc(text, placeholder)
99+
protected = codeSpanRegex.ReplaceAllStringFunc(protected, placeholder)
100+
101+
// Find all @Name matches with positions
102+
type mentionMatch struct {
103+
start int // start of @Name (the @ character)
104+
end int // end of @Name
105+
name string
106+
}
107+
108+
allMatches := mentionRegex.FindAllStringSubmatchIndex(protected, -1)
109+
var matches []mentionMatch
110+
for _, loc := range allMatches {
111+
// loc[2]:loc[3] is the capture group (the name without @)
112+
nameStart := loc[2]
113+
nameEnd := loc[3]
114+
// The @ is one character before the name
115+
atStart := nameStart - 1
116+
name := protected[nameStart:nameEnd]
117+
matches = append(matches, mentionMatch{start: atStart, end: nameEnd, name: name})
118+
}
119+
120+
// Process from end to start so replacements don't shift indices
121+
for i := len(matches) - 1; i >= 0; i-- {
122+
m := matches[i]
123+
124+
// Find matching user by first name (case-insensitive)
125+
var found []mentionUser
126+
for _, u := range mentionUsers {
127+
if strings.EqualFold(u.FirstName, m.name) {
128+
found = append(found, u)
129+
}
130+
}
131+
132+
switch len(found) {
133+
case 1:
134+
mentionHTML := buildMentionHTML(found[0])
135+
protected = protected[:m.start] + mentionHTML + protected[m.end:]
136+
case 0:
137+
fmt.Fprintf(os.Stderr, "Warning: could not resolve mention @%s\n", m.name)
138+
default:
139+
names := make([]string, len(found))
140+
for j, u := range found {
141+
names[j] = u.FullName
142+
}
143+
fmt.Fprintf(os.Stderr, "Warning: ambiguous mention @%s — matches: %s\n", m.name, strings.Join(names, ", "))
144+
}
145+
}
146+
147+
// Restore code blocks and spans
148+
for i, chunk := range codeChunks {
149+
protected = strings.Replace(protected, fmt.Sprintf("\x00CODE%d\x00", i), chunk, 1)
150+
}
151+
152+
return protected
153+
}
154+
155+
// fetchMentionUsers fetches the list of mentionable users from the API.
156+
func fetchMentionUsers(c client.API) ([]mentionUser, error) {
157+
resp, err := c.GetHTML("/prompts/users")
158+
if err != nil {
159+
return nil, err
160+
}
161+
if resp.StatusCode != 200 {
162+
return nil, fmt.Errorf("unexpected status %d from /prompts/users", resp.StatusCode)
163+
}
164+
return parseMentionUsers(resp.Body), nil
165+
}
166+
167+
// parseMentionUsers extracts mentionable users from the /prompts/users HTML.
168+
// Each user is represented as a <lexxy-prompt-item> element with search and sgid
169+
// attributes, containing <img> tags with avatar URLs.
170+
func parseMentionUsers(htmlBytes []byte) []mentionUser {
171+
htmlStr := string(htmlBytes)
172+
items := promptItemRegex.FindAllStringIndex(htmlStr, -1)
173+
if len(items) == 0 {
174+
return nil
175+
}
176+
177+
// Find all closing tags for scoping avatar lookups
178+
endIndices := promptItemEndRegex.FindAllStringIndex(htmlStr, -1)
179+
180+
var users []mentionUser
181+
for itemIdx, loc := range items {
182+
tag := htmlStr[loc[0]:loc[1]]
183+
184+
// Extract search and sgid attributes (order-independent)
185+
searchMatch := searchAttrRegex.FindStringSubmatch(tag)
186+
sgidMatch := sgidAttrRegex.FindStringSubmatch(tag)
187+
if searchMatch == nil || sgidMatch == nil {
188+
continue
189+
}
190+
191+
search := strings.TrimSpace(searchMatch[1])
192+
sgid := sgidMatch[1]
193+
194+
if search == "" || sgid == "" {
195+
continue
196+
}
197+
198+
// Parse name from search attribute.
199+
// Format: "Full Name INITIALS [me]"
200+
// Strip trailing "me" and all-uppercase words (initials like "WS", "FMA").
201+
words := strings.Fields(search)
202+
for len(words) > 1 {
203+
last := words[len(words)-1]
204+
if last == "me" || isAllUpper(last) {
205+
words = words[:len(words)-1]
206+
} else {
207+
break
208+
}
209+
}
210+
211+
fullName := strings.Join(words, " ")
212+
firstName := words[0]
213+
214+
// Extract avatar URL scoped to this prompt-item block only.
215+
avatarSrc := ""
216+
blockStart := loc[0]
217+
blockEnd := len(htmlStr)
218+
if itemIdx < len(endIndices) {
219+
blockEnd = endIndices[itemIdx][1]
220+
}
221+
block := htmlStr[blockStart:blockEnd]
222+
if m := avatarRegex.FindStringSubmatch(block); len(m) > 1 {
223+
avatarSrc = m[1]
224+
}
225+
226+
users = append(users, mentionUser{
227+
FirstName: firstName,
228+
FullName: fullName,
229+
SGID: sgid,
230+
AvatarSrc: avatarSrc,
231+
})
232+
}
233+
234+
return users
235+
}
236+
237+
// buildMentionHTML creates the ActionText attachment HTML for a mention.
238+
// Values are HTML-escaped to prevent injection from user-controlled names.
239+
func buildMentionHTML(u mentionUser) string {
240+
return fmt.Sprintf(
241+
`<action-text-attachment sgid="%s" content-type="application/vnd.actiontext.mention">`+
242+
`<img title="%s" src="%s" width="48" height="48">%s`+
243+
`</action-text-attachment>`,
244+
html.EscapeString(u.SGID),
245+
html.EscapeString(u.FullName),
246+
html.EscapeString(u.AvatarSrc),
247+
html.EscapeString(u.FirstName),
248+
)
249+
}
250+
251+
// isAllUpper returns true if s is non-empty and all uppercase letters.
252+
func isAllUpper(s string) bool {
253+
if s == "" {
254+
return false
255+
}
256+
for _, r := range s {
257+
if !unicode.IsUpper(r) {
258+
return false
259+
}
260+
}
261+
return true
262+
}

0 commit comments

Comments
 (0)