diff --git a/.surface b/.surface index 8092d5922..308ea452a 100644 --- a/.surface +++ b/.surface @@ -182,6 +182,8 @@ ARG basecamp documents uploads create 00 ARG basecamp documents vault create 00 ARG basecamp documents vaults create 00 ARG basecamp documents versions 00 +ARG basecamp embeds create 00 +ARG basecamp embeds show 00 ARG basecamp events 00 [id|url] ARG basecamp file archive 00 ARG basecamp file doc create 00 @@ -788,6 +790,9 @@ CMD basecamp documents vaults list CMD basecamp documents versions CMD basecamp drafts CMD basecamp drafts list +CMD basecamp embeds +CMD basecamp embeds create +CMD basecamp embeds show CMD basecamp events CMD basecamp events poll CMD basecamp events ticket @@ -7532,6 +7537,69 @@ FLAG basecamp drafts list --stats type=bool FLAG basecamp drafts list --styled type=bool FLAG basecamp drafts list --todolist type=string FLAG basecamp drafts list --verbose type=count +FLAG basecamp embeds --account type=string +FLAG basecamp embeds --agent type=bool +FLAG basecamp embeds --cache-dir type=string +FLAG basecamp embeds --count type=bool +FLAG basecamp embeds --help type=bool +FLAG basecamp embeds --hints type=bool +FLAG basecamp embeds --ids-only type=bool +FLAG basecamp embeds --in type=string +FLAG basecamp embeds --jq type=string +FLAG basecamp embeds --json type=bool +FLAG basecamp embeds --markdown type=bool +FLAG basecamp embeds --md type=bool +FLAG basecamp embeds --no-hints type=bool +FLAG basecamp embeds --no-stats type=bool +FLAG basecamp embeds --profile type=string +FLAG basecamp embeds --project type=string +FLAG basecamp embeds --quiet type=bool +FLAG basecamp embeds --stats type=bool +FLAG basecamp embeds --styled type=bool +FLAG basecamp embeds --todolist type=string +FLAG basecamp embeds --verbose type=count +FLAG basecamp embeds create --account type=string +FLAG basecamp embeds create --agent type=bool +FLAG basecamp embeds create --cache-dir type=string +FLAG basecamp embeds create --count type=bool +FLAG basecamp embeds create --help type=bool +FLAG basecamp embeds create --hints type=bool +FLAG basecamp embeds create --ids-only type=bool +FLAG basecamp embeds create --in type=string +FLAG basecamp embeds create --jq type=string +FLAG basecamp embeds create --json type=bool +FLAG basecamp embeds create --markdown type=bool +FLAG basecamp embeds create --md type=bool +FLAG basecamp embeds create --no-hints type=bool +FLAG basecamp embeds create --no-stats type=bool +FLAG basecamp embeds create --profile type=string +FLAG basecamp embeds create --project type=string +FLAG basecamp embeds create --quiet type=bool +FLAG basecamp embeds create --stats type=bool +FLAG basecamp embeds create --styled type=bool +FLAG basecamp embeds create --todolist type=string +FLAG basecamp embeds create --verbose type=count +FLAG basecamp embeds show --account type=string +FLAG basecamp embeds show --agent type=bool +FLAG basecamp embeds show --cache-dir type=string +FLAG basecamp embeds show --count type=bool +FLAG basecamp embeds show --help type=bool +FLAG basecamp embeds show --hints type=bool +FLAG basecamp embeds show --ids-only type=bool +FLAG basecamp embeds show --in type=string +FLAG basecamp embeds show --jq type=string +FLAG basecamp embeds show --json type=bool +FLAG basecamp embeds show --markdown type=bool +FLAG basecamp embeds show --md type=bool +FLAG basecamp embeds show --no-hints type=bool +FLAG basecamp embeds show --no-stats type=bool +FLAG basecamp embeds show --profile type=string +FLAG basecamp embeds show --project type=string +FLAG basecamp embeds show --quiet type=bool +FLAG basecamp embeds show --stats type=bool +FLAG basecamp embeds show --styled type=bool +FLAG basecamp embeds show --todolist type=string +FLAG basecamp embeds show --verbose type=count FLAG basecamp events --account type=string FLAG basecamp events --agent type=bool FLAG basecamp events --all type=bool @@ -19520,6 +19588,9 @@ SUB basecamp documents vaults list SUB basecamp documents versions SUB basecamp drafts SUB basecamp drafts list +SUB basecamp embeds +SUB basecamp embeds create +SUB basecamp embeds show SUB basecamp events SUB basecamp events poll SUB basecamp events ticket diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 4d50b0574..58733df26 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,14 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 53 | 207 | +| ✅ Implemented | 54 | 209 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **57** | **219** | +| **Total tracked** | **58** | **221** | -**207 of 207 tracked in-scope endpoints.** The three Pings endpoints bc3#13943 +**209 of 209 tracked in-scope endpoints.** The two embed endpoints bc3 documents +in `embeds.md` — `POST /embeds.json` and `GET /embeds/:sgid.json` — land as +`embeds`. The three Pings endpoints bc3#13943 documented — `GET /circles.json`, `GET /circles/:id.json` and `POST /circles.json` — land as `pings`. Moving a document, upload or folder between folders (`POST /recordings/:id/filing.json`, documented by bc3#13644 and @@ -238,6 +240,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A | vaults (move) | 1 | `files move` | ✅ | BC5 | - | `POST /recordings/:id/filing.json`: moves a document, upload or folder into another folder in the same project, in place (same id and comments). `--to` takes a folder ID or URL; `--position` is 1-indexed. Flat route, so no `--in` | | documents | 8 | `files`, `docs` | ✅ | BC4 | - | list, show, create, update. Create supports `--subscribe`/`--no-subscribe`, `--visible-to-clients` (root vault only) | | attachments | 1 | `uploads`, `attachments` | ✅ | BC4 | - | Upload via `attach`; list embedded attachments via `attachments list` (parses `<bc-attachment>` from content) | +| embeds | 2 | `embeds` | ✅ | BC5 | - | create, show. Rich-text commands also embed a URL that stands alone on its line in Markdown content, through the same create | | **Schedule** | | calendars | 2 | `calendars` | ✅ | BC5 | - | show, update (color only). No index endpoint, so there is no `calendars list` — address one by id or pasted URL. The eleven colors are validated client-side for an immediate answer that names the alternatives; since v0.13.0 the SDK also carries the server's field-keyed 422 message | | schedules | 2 | `schedule` | ✅ | BC4 | - | Schedule container + settings | diff --git a/e2e/smoke/run_smoke.sh b/e2e/smoke/run_smoke.sh index ab624bcac..99aff3a23 100755 --- a/e2e/smoke/run_smoke.sh +++ b/e2e/smoke/run_smoke.sh @@ -101,6 +101,7 @@ level1=( "$SMOKE_DIR"/smoke_assign.bats "$SMOKE_DIR"/smoke_lineup.bats "$SMOKE_DIR"/smoke_communication_write.bats + "$SMOKE_DIR"/smoke_embeds.bats "$SMOKE_DIR"/smoke_misc_write.bats "$SMOKE_DIR"/smoke_tools.bats "$SMOKE_DIR"/smoke_cards_column_write.bats diff --git a/e2e/smoke/smoke_embeds.bats b/e2e/smoke/smoke_embeds.bats new file mode 100644 index 000000000..991086277 --- /dev/null +++ b/e2e/smoke/smoke_embeds.bats @@ -0,0 +1,32 @@ +#!/usr/bin/env bats +# smoke_embeds.bats - Level 1: Embeds for rich text + +load smoke_helper + +setup_file() { + ensure_token || return 1 +} + +@test "embeds without subcommand shows help" { + run basecamp embeds + assert_success + assert_output_contains "create" +} + +@test "embeds create returns an embed with its sgid" { + run_smoke basecamp embeds create "https://www.youtube.com/watch?v=dQw4w9WgXcQ" --json + if [[ "$status" -ne 0 ]] && [[ "$output" == *"not found"* || "$output" == *"404"* ]]; then + mark_unverifiable "This Basecamp does not serve POST /embeds.json to API clients yet" + return + fi + assert_success + assert_json_not_null '.data.sgid' + echo "$output" | jq -r '.data.sgid' > "$BATS_FILE_TMPDIR/embed_sgid" +} + +@test "embeds show returns the embed" { + [[ -s "$BATS_FILE_TMPDIR/embed_sgid" ]] || mark_unverifiable "No embed was created to show" + run_smoke basecamp embeds show "$(cat "$BATS_FILE_TMPDIR/embed_sgid")" --json + assert_success + assert_json_value '.data.url' 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' +} diff --git a/go.mod b/go.mod index ac592f6f2..97018b094 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.26.0 + github.com/basecamp/basecamp-sdk/go v0.27.0 github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/basecamp/surfguard/go v0.1.0 diff --git a/go.sum b/go.sum index f28371adc..62ea780c7 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.26.0 h1:ENIK+0Q9WWRCzcJjz0RLLpvAMSfKoQOl8lHRumaEeOg= -github.com/basecamp/basecamp-sdk/go v0.26.0/go.mod h1:PAp3gBnEPH7LtAyWXwgQ1tfmCAJiMkRHgRJAKT/Xefc= +github.com/basecamp/basecamp-sdk/go v0.27.0 h1:Shq2lyAMBDvvBtmVLZZes42UyKkFMIvpR1Pa8xyWOVo= +github.com/basecamp/basecamp-sdk/go v0.27.0/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d h1:jAzDrCCzDpIwhbFT1xVVs0z2xpXoDEkomHfKB2bUUp8= github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= diff --git a/internal/cli/root.go b/internal/cli/root.go index 9c34bfd5e..1069c8a3b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -362,6 +362,7 @@ func Execute() { cmd.AddCommand(commands.NewProfileCmd()) cmd.AddCommand(commands.NewSkillCmd()) cmd.AddCommand(commands.NewAttachmentsCmd()) + cmd.AddCommand(commands.NewEmbedsCmd()) cmd.AddCommand(commands.NewAttachCmd()) cmd.AddCommand(commands.NewUploadCmd()) cmd.AddCommand(commands.NewGaugesCmd()) diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 604595a68..84f57d6f2 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -104,6 +104,7 @@ func CommandCategories() []CommandCategory { {Name: "forwards", Category: "communication", Description: "Manage email forwards (inbox)", Actions: []string{"list", "show", "inbox", "replies", "reply"}}, {Name: "subscriptions", Category: "communication", Description: "Manage notification subscriptions", Actions: []string{"show", "subscribe", "unsubscribe", "add", "remove"}}, {Name: "attachments", Category: "communication", Description: "List and download attachments", Actions: []string{"list", "download"}}, + {Name: "embeds", Category: "communication", Description: "Embed videos, charts and other links in rich text", Actions: []string{"create", "show"}}, {Name: "comments", Category: "communication", Description: "Manage comments", Actions: []string{"create", "list", "show", "thread", "update", "trash", "archive", "restore"}}, {Name: "boost", Category: "communication", Description: "Manage boosts (reactions)", Actions: []string{"list", "show", "create", "update", "delete"}}, {Name: "notifications", Category: "communication", Description: "View and manage notifications", Actions: []string{"list", "read", "bubbleups"}}, diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 1710ef1eb..25de382b7 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -111,6 +111,7 @@ func buildRootWithAllCommands() *cobra.Command { root.AddCommand(commands.NewUpgradeCmd()) root.AddCommand(commands.NewMigrateCmd()) root.AddCommand(commands.NewAttachmentsCmd()) + root.AddCommand(commands.NewEmbedsCmd()) root.AddCommand(commands.NewAttachCmd()) root.AddCommand(commands.NewUploadCmd()) root.AddCommand(commands.NewSkillCmd()) diff --git a/internal/commands/embeds.go b/internal/commands/embeds.go new file mode 100644 index 000000000..628598c1c --- /dev/null +++ b/internal/commands/embeds.go @@ -0,0 +1,265 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/output" + "github.com/basecamp/basecamp-cli/internal/richtext" +) + +type embedClient interface { + Create(ctx context.Context, url string) (*basecamp.Embed, error) + Get(ctx context.Context, sgid string) (*basecamp.Embed, error) +} + +var ( + embedPollInterval = time.Second + embedWait = 10 * time.Second +) + +type EmbedResult struct { + *basecamp.Embed + Attachment string `json:"attachment,omitempty"` +} + +func NewEmbedsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "embeds", + Short: "Embed videos, charts and other links in rich text", + Long: `Embed content from another site, such as a YouTube video or a Dash chart, +in a card, comment, message, document or to-do. + +With Markdown content (the default), you don't need this command: put the URL +on a line by itself and Basecamp embeds it, as the editor does when you paste +a link. Write [title](url) to keep a link instead. + +Use 'create --json' when you write HTML (--format html): its attachment +field is the <bc-attachment> tag to put in your content.`, + Annotations: map[string]string{"agent_notes": "A URL alone on its line in Markdown content becomes an embed automatically\nWrite [title](url) to keep it a link\nURLs Basecamp can't embed stay links, with no warning\nWith --format html, use embeds create --json and paste its attachment field"}, + } + + cmd.AddCommand( + newEmbedsCreateCmd(), + newEmbedsShowCmd(), + ) + + return cmd +} + +func newEmbedsCreateCmd() *cobra.Command { + return &cobra.Command{ + Use: "create <url>", + Short: "Create an embed for a URL", + Long: `Create an embed for a URL and wait until Basecamp has it from the provider. + +With --json, the embed's attachment field is the <bc-attachment> tag to put +in HTML rich text. +A URL no provider embeds is refused; link to it instead.`, + Example: ` basecamp embeds create https://www.youtube.com/watch?v=dQw4w9WgXcQ + basecamp embeds create https://dash.37signals.com/widgets/abc --json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 || strings.TrimSpace(args[0]) == "" { + return missingArg(cmd, "<url>") + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + embed, err := createEmbed(cmd.Context(), app.Account().Embeds(), args[0]) + if err != nil { + return err + } + return app.OK(embedResult(embed), output.WithSummary(embedSummary(embed))) + }, + } +} + +func newEmbedsShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show <sgid>", + Short: "Show an embed", + Long: "Show an embed by the sgid that 'embeds create' printed.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 || strings.TrimSpace(args[0]) == "" { + return missingArg(cmd, "<sgid>") + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + embed, err := app.Account().Embeds().Get(cmd.Context(), args[0]) + if err != nil { + return convertSDKError(err) + } + return app.OK(embedResult(embed), output.WithSummary(embedSummary(embed))) + }, + } +} + +func createEmbed(ctx context.Context, client embedClient, url string) (*basecamp.Embed, error) { + embed, err := client.Create(ctx, url) + if err != nil { + if httpStatus(err) == http.StatusUnprocessableEntity { + return nil, output.ErrUsageHint("Basecamp can't embed "+url, "Link to it instead") + } + return nil, convertSDKError(err) + } + return waitForEmbed(ctx, client, embed) +} + +func waitForEmbed(ctx context.Context, client embedClient, embed *basecamp.Embed) (*basecamp.Embed, error) { + ctx, cancel := context.WithTimeout(ctx, embedWait) + defer cancel() + + for embed.Status == "pending" && pauseForEmbed(ctx) { + refreshed, err := client.Get(ctx, embed.SGID) + if err != nil { + if ctx.Err() != nil { + break + } + return nil, convertSDKError(err) + } + embed = refreshed + } + return embed, nil +} + +func pauseForEmbed(ctx context.Context) bool { + select { + case <-ctx.Done(): + return false + case <-time.After(embedPollInterval): + return true + } +} + +func embedResult(embed *basecamp.Embed) EmbedResult { + result := EmbedResult{Embed: embed} + if embed.Status == "active" { + result.Attachment = richtext.EmbedToHTML(richtext.EmbedRef{SGID: embed.SGID, ContentType: embed.ContentType}) + } + return result +} + +func embedSummary(embed *basecamp.Embed) string { + switch embed.Status { + case "active": + return "Embed ready for " + embed.URL + case "pending": + return "Embed still pending for " + embed.URL + "; check again with basecamp embeds show" + default: + return "Basecamp found nothing to embed at " + embed.URL + "; link to it instead" + } +} + +func embedLinks(cmd *cobra.Command) richtext.EmbedFunc { + app := appctx.FromContext(cmd.Context()) + if app == nil || app.SDK == nil || app.RequireAccount() != nil { + return nil + } + return func(urls []string) map[string]richtext.EmbedRef { + return resolveEmbeds(cmd.Context(), app.Account().Embeds(), urls, cmd.ErrOrStderr()) + } +} + +func resolveEmbeds(ctx context.Context, client embedClient, urls []string, warnings io.Writer) map[string]richtext.EmbedRef { + ctx, cancel := context.WithTimeout(ctx, embedWait) + defer cancel() + + var created []string + var failure error + embeds := map[string]*basecamp.Embed{} +creating: + for i, url := range urls { + embed, err := client.Create(ctx, url) + if err != nil { + switch httpStatus(err) { + case http.StatusUnprocessableEntity: + continue + case http.StatusNotFound: + break creating + } + failure = err + warnEmbed(warnings, "Warning: couldn't embed %s, so it stays a link: %v", url, err) + switch rest := len(urls) - i - 1; rest { + case 0: + case 1: + warnEmbed(warnings, "Warning: the URL after it stays a link too.") + default: + warnEmbed(warnings, "Warning: the %d URLs after it stay links too.", rest) + } + break creating + } + created = append(created, url) + embeds[url] = embed + } + + for failure == nil && anyPendingEmbed(created, embeds) && pauseForEmbed(ctx) { + for _, url := range created { + if embeds[url].Status != "pending" { + continue + } + refreshed, err := client.Get(ctx, embeds[url].SGID) + if err != nil { + if ctx.Err() == nil { + failure = err + } + break + } + embeds[url] = refreshed + } + } + + refs := map[string]richtext.EmbedRef{} + for _, url := range created { + switch embed := embeds[url]; { + case embed.Status == "active": + refs[url] = richtext.EmbedRef{SGID: embed.SGID, ContentType: embed.ContentType} + case embed.Status == "pending" && failure != nil: + warnEmbed(warnings, "Warning: couldn't embed %s, so it stays a link: %v", url, failure) + case embed.Status == "pending": + warnEmbed(warnings, "Warning: %s took too long to answer, so it stays a link.", url) + default: + warnEmbed(warnings, "Warning: Basecamp found nothing to embed at %s, so it stays a link.", url) + } + } + return refs +} + +func warnEmbed(warnings io.Writer, format string, args ...any) { + fmt.Fprintln(warnings, richtext.SanitizeSingleLine(fmt.Sprintf(format, args...))) +} + +func anyPendingEmbed(urls []string, embeds map[string]*basecamp.Embed) bool { + for _, url := range urls { + if embeds[url].Status == "pending" { + return true + } + } + return false +} + +func httpStatus(err error) int { + var apiErr *basecamp.Error + if errors.As(err, &apiErr) { + return apiErr.HTTPStatus + } + return 0 +} diff --git a/internal/commands/embeds_test.go b/internal/commands/embeds_test.go new file mode 100644 index 000000000..a4f21c1b4 --- /dev/null +++ b/internal/commands/embeds_test.go @@ -0,0 +1,449 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/output" +) + +const ( + embedVideoURL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + embedChartURL = "https://dash.37signals.com/widgets/abc" + embedCardURL = "https://3.basecamp.com/99999/buckets/123/card_tables/cards/789" +) + +type embedReply struct { + status int + body string +} + +type embedTransport struct { + mu sync.Mutex + hang bool + creates map[string]embedReply + gets map[string][]embedReply + calls []string + posted []byte +} + +func (t *embedTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.mu.Lock() + defer t.mu.Unlock() + + respond := func(reply embedReply) (*http.Response, error) { + return &http.Response{ + StatusCode: reply.status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(reply.body)), + Request: req, + }, nil + } + + var body []byte + if req.Body != nil { + body, _ = io.ReadAll(req.Body) + req.Body.Close() + } + + switch { + case req.Method == http.MethodPost && req.URL.Path == "/99999/embeds.json" && t.hang: + t.calls = append(t.calls, "create (hung)") + t.mu.Unlock() + <-req.Context().Done() + t.mu.Lock() + return nil, req.Context().Err() + case req.Method == http.MethodPost && req.URL.Path == "/99999/embeds.json": + var params struct { + URL string `json:"url"` + } + _ = json.Unmarshal(body, ¶ms) + t.calls = append(t.calls, "create "+params.URL) + if reply, ok := t.creates[params.URL]; ok { + return respond(reply) + } + return respond(embedReply{http.StatusUnprocessableEntity, `{"error":"Basecamp can't embed this URL. Link to it instead."}`}) + case req.Method == http.MethodGet && strings.HasPrefix(req.URL.Path, "/99999/embeds/"): + sgid := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/99999/embeds/"), ".json") + t.calls = append(t.calls, "get "+sgid) + replies := t.gets[sgid] + if len(replies) == 0 { + return respond(embedReply{http.StatusNotFound, `{"error":"not found"}`}) + } + reply := replies[0] + if len(replies) > 1 { + t.gets[sgid] = replies[1:] + } + return respond(reply) + case req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/comments.json"): + t.calls = append(t.calls, "comment") + t.posted = body + return respond(embedReply{http.StatusCreated, `{"id": 999, "content": "ok", "created_at": "2024-01-01T00:00:00Z"}`}) + default: + return respond(embedReply{http.StatusNotFound, `{"error":"not found"}`}) + } +} + +func (t *embedTransport) postedContent(tb testing.TB) string { + tb.Helper() + t.mu.Lock() + defer t.mu.Unlock() + require.NotEmpty(tb, t.posted, "nothing was posted") + var body map[string]any + require.NoError(tb, json.Unmarshal(t.posted, &body)) + content, _ := body["content"].(string) + return content +} + +func (t *embedTransport) recordedCalls() []string { + t.mu.Lock() + defer t.mu.Unlock() + return append([]string(nil), t.calls...) +} + +func embedJSON(url, status, sgid string) string { + embed := map[string]any{"url": url, "status": status, "sgid": sgid} + if status == "active" { + embed["type"] = "video" + embed["content_type"] = "embed/youtube-video" + } + b, _ := json.Marshal(embed) + return string(b) +} + +func fastEmbedPolling(t *testing.T, wait time.Duration) { + t.Helper() + interval, deadline := embedPollInterval, embedWait + embedPollInterval, embedWait = time.Millisecond, wait + t.Cleanup(func() { embedPollInterval, embedWait = interval, deadline }) +} + +func runEmbedCommand(cmd *cobra.Command, app *appctx.App, args ...string) (string, error) { + cmd.SetArgs(args) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetOut(&bytes.Buffer{}) + stderr := &bytes.Buffer{} + cmd.SetErr(stderr) + err := cmd.Execute() + return stderr.String(), err +} + +func TestCommentsCreateEmbedsAURLAloneOnItsLine(t *testing.T) { + fastEmbedPolling(t, time.Second) + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: {http.StatusCreated, embedJSON(embedVideoURL, "pending", "sgid-video")}}, + gets: map[string][]embedReply{"sgid-video": { + {http.StatusOK, embedJSON(embedVideoURL, "pending", "sgid-video")}, + {http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}, + }}, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, "Watch this:\n"+embedVideoURL) + require.NoError(t, err) + + assert.Equal(t, "<p>Watch this:</p>\n"+`<bc-attachment sgid="sgid-video" content-type="embed/youtube-video"></bc-attachment>`, + transport.postedContent(t)) + assert.Equal(t, []string{"create " + embedVideoURL, "get sgid-video", "get sgid-video", "comment"}, transport.recordedCalls()) + assert.Empty(t, stderr) +} + +func TestCommentsCreateEmbedsEveryURLBeforeWaitingOnAny(t *testing.T) { + fastEmbedPolling(t, time.Second) + transport := &embedTransport{ + creates: map[string]embedReply{ + embedChartURL: {http.StatusCreated, embedJSON(embedChartURL, "pending", "sgid-chart")}, + embedVideoURL: {http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}, + }, + gets: map[string][]embedReply{"sgid-chart": {{http.StatusOK, embedJSON(embedChartURL, "active", "sgid-chart")}}}, + } + app, _ := newTestAppWithTransport(t, transport) + + _, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedChartURL+"\n"+embedVideoURL+"\n\n"+embedChartURL) + require.NoError(t, err) + + assert.Equal(t, []string{"create " + embedChartURL, "create " + embedVideoURL, "get sgid-chart", "comment"}, transport.recordedCalls()) + assert.Equal(t, 2, strings.Count(transport.postedContent(t), `sgid="sgid-chart"`)) + assert.Contains(t, transport.postedContent(t), `sgid="sgid-video"`) +} + +func TestCommentsCreateLeavesAURLBasecampCanNotEmbedAsALinkQuietly(t *testing.T) { + transport := &embedTransport{} + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, "https://github.com/basecamp/bc3/pull/1") + require.NoError(t, err) + + assert.Equal(t, "<p>https://github.com/basecamp/bc3/pull/1</p>", transport.postedContent(t)) + assert.Empty(t, stderr) +} + +func TestCommentsCreateStopsQuietlyWhereBasecampHasNoEmbeds(t *testing.T) { + transport := &embedTransport{ + creates: map[string]embedReply{ + embedChartURL: {http.StatusNotFound, `{"error":"not found"}`}, + embedVideoURL: {http.StatusNotFound, `{"error":"not found"}`}, + }, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedChartURL+"\n"+embedVideoURL) + require.NoError(t, err) + + assert.Equal(t, []string{"create " + embedChartURL, "comment"}, transport.recordedCalls()) + assert.Equal(t, "<p>"+embedChartURL+"\n"+embedVideoURL+"</p>", transport.postedContent(t)) + assert.Empty(t, stderr) +} + +func TestCommentsCreateLeavesALinkWhenTheEmbedFails(t *testing.T) { + for name, tc := range map[string]struct { + create embedReply + gets []embedReply + warning string + }{ + "rate limited": { + create: embedReply{http.StatusTooManyRequests, `{"error":"Too many new embeds. Try again later."}`}, + warning: "couldn't embed " + embedVideoURL + ", so it stays a link", + }, + "refused to agents": { + create: embedReply{http.StatusForbidden, `{"error":"Basecamp doesn't let agents do this.","reason":"agent_not_permitted"}`}, + warning: "couldn't embed " + embedVideoURL + ", so it stays a link", + }, + "the provider has nothing": { + create: embedReply{http.StatusCreated, embedJSON(embedVideoURL, "pending", "sgid-video")}, + gets: []embedReply{{http.StatusOK, embedJSON(embedVideoURL, "not_found", "sgid-video")}}, + warning: "Basecamp found nothing to embed at " + embedVideoURL + ", so it stays a link.", + }, + "the provider is slow": { + create: embedReply{http.StatusCreated, embedJSON(embedVideoURL, "pending", "sgid-video")}, + gets: []embedReply{{http.StatusOK, embedJSON(embedVideoURL, "pending", "sgid-video")}}, + warning: embedVideoURL + " took too long to answer, so it stays a link.", + }, + "the embed vanishes": { + create: embedReply{http.StatusCreated, embedJSON(embedVideoURL, "pending", "sgid-video")}, + gets: []embedReply{{http.StatusNotFound, `{"error":"not found"}`}}, + warning: "couldn't embed " + embedVideoURL + ", so it stays a link", + }, + } { + t.Run(name, func(t *testing.T) { + fastEmbedPolling(t, 20*time.Millisecond) + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: tc.create}, + gets: map[string][]embedReply{"sgid-video": tc.gets}, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedVideoURL) + require.NoError(t, err) + + assert.Equal(t, "<p>"+embedVideoURL+"</p>", transport.postedContent(t)) + assert.Contains(t, stderr, tc.warning) + }) + } +} + +func TestCommentsCreateKeepsPollingTheOthersWhileOneProviderIsSlow(t *testing.T) { + fastEmbedPolling(t, 50*time.Millisecond) + transport := &embedTransport{ + creates: map[string]embedReply{ + embedChartURL: {http.StatusOK, embedJSON(embedChartURL, "pending", "sgid-chart")}, + embedVideoURL: {http.StatusOK, embedJSON(embedVideoURL, "pending", "sgid-video")}, + }, + gets: map[string][]embedReply{ + "sgid-chart": {{http.StatusOK, embedJSON(embedChartURL, "pending", "sgid-chart")}}, + "sgid-video": {{http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}}, + }, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedChartURL+"\n"+embedVideoURL) + require.NoError(t, err) + + assert.Equal(t, "<p>"+embedChartURL+"</p>\n"+`<bc-attachment sgid="sgid-video" content-type="embed/youtube-video"></bc-attachment>`, transport.postedContent(t)) + assert.Contains(t, stderr, embedChartURL+" took too long to answer") +} + +func TestCommentsCreateStopsAskingForEmbedsAfterAFailure(t *testing.T) { + transport := &embedTransport{ + creates: map[string]embedReply{ + embedChartURL: {http.StatusInternalServerError, `{"error":"boom"}`}, + embedVideoURL: {http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}, + }, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedChartURL+"\n"+embedVideoURL) + require.NoError(t, err) + + assert.Equal(t, "<p>"+embedChartURL+"\n"+embedVideoURL+"</p>", transport.postedContent(t)) + assert.Equal(t, 1, strings.Count(strings.Join(transport.recordedCalls(), ","), "create ")) + assert.Contains(t, stderr, "the URL after it stays a link too") +} + +func TestCommentsCreateStopsPollingAfterAFailure(t *testing.T) { + fastEmbedPolling(t, time.Second) + transport := &embedTransport{ + creates: map[string]embedReply{ + embedChartURL: {http.StatusOK, embedJSON(embedChartURL, "pending", "sgid-chart")}, + embedVideoURL: {http.StatusInternalServerError, `{"error":"boom"}`}, + }, + gets: map[string][]embedReply{"sgid-chart": {{http.StatusOK, embedJSON(embedChartURL, "active", "sgid-chart")}}}, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedChartURL+"\n"+embedVideoURL) + require.NoError(t, err) + + assert.Equal(t, []string{"create " + embedChartURL, "create " + embedVideoURL, "comment"}, transport.recordedCalls()) + assert.Equal(t, "<p>"+embedChartURL+"\n"+embedVideoURL+"</p>", transport.postedContent(t)) + assert.Contains(t, stderr, "couldn't embed "+embedChartURL) +} + +func TestCommentsCreateWarnsOnOneCleanLine(t *testing.T) { + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: {http.StatusInternalServerError, `{"error":"boom\u001b[31m\nWarning: forged"}`}}, + } + app, _ := newTestAppWithTransport(t, transport) + + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedVideoURL) + require.NoError(t, err) + + assert.NotContains(t, stderr, "\x1b") + assert.Equal(t, 1, strings.Count(stderr, "\n"), stderr) + assert.Contains(t, stderr, "couldn't embed "+embedVideoURL) +} + +func TestCommentsCreatePostsWhenAnEmbedRequestHangs(t *testing.T) { + fastEmbedPolling(t, 50*time.Millisecond) + transport := &embedTransport{hang: true} + app, _ := newTestAppWithTransport(t, transport) + + started := time.Now() + stderr, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, embedVideoURL) + require.NoError(t, err) + + assert.Less(t, time.Since(started), 5*time.Second) + assert.Equal(t, "<p>"+embedVideoURL+"</p>", transport.postedContent(t)) + assert.Contains(t, stderr, "couldn't embed "+embedVideoURL) +} + +func TestCommentsCreateWithFormatHTMLEmbedsNothing(t *testing.T) { + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: {http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}}, + } + app, _ := newTestAppWithTransport(t, transport) + + html := "<p>" + embedVideoURL + "</p>\n" + embedVideoURL + _, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, html, "--format", "html") + require.NoError(t, err) + + assert.Equal(t, html, transport.postedContent(t)) + assert.Equal(t, []string{"comment"}, transport.recordedCalls()) +} + +func TestCommentsCreateKeepsATitledLink(t *testing.T) { + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: {http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}}, + } + app, _ := newTestAppWithTransport(t, transport) + + _, err := runEmbedCommand(NewCommentsCmd(), app, "create", embedCardURL, "[The video]("+embedVideoURL+")") + require.NoError(t, err) + + assert.Equal(t, `<p><a href="`+embedVideoURL+`">The video</a></p>`, transport.postedContent(t)) + assert.Equal(t, []string{"comment"}, transport.recordedCalls()) +} + +func TestEmbedsCreateWaitsAndPrintsTheAttachment(t *testing.T) { + fastEmbedPolling(t, time.Second) + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: {http.StatusCreated, embedJSON(embedVideoURL, "pending", "sgid-video")}}, + gets: map[string][]embedReply{"sgid-video": {{http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}}}, + } + app, out := newTestAppWithTransport(t, transport) + + _, err := runEmbedCommand(NewEmbedsCmd(), app, "create", embedVideoURL) + require.NoError(t, err) + + var envelope struct { + Data struct { + URL string `json:"url"` + Status string `json:"status"` + SGID string `json:"sgid"` + Attachment string `json:"attachment"` + } `json:"data"` + Summary string `json:"summary"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &envelope)) + assert.Equal(t, "active", envelope.Data.Status) + assert.Equal(t, "sgid-video", envelope.Data.SGID) + assert.Equal(t, `<bc-attachment sgid="sgid-video" content-type="embed/youtube-video"></bc-attachment>`, envelope.Data.Attachment) + assert.Equal(t, "Embed ready for "+embedVideoURL, envelope.Summary) +} + +func TestEmbedsCreateRefusesAURLBasecampCanNotEmbed(t *testing.T) { + transport := &embedTransport{} + app, _ := newTestAppWithTransport(t, transport) + + _, err := runEmbedCommand(NewEmbedsCmd(), app, "create", "https://example.com") + + var outErr *output.Error + require.ErrorAs(t, err, &outErr) + assert.Equal(t, "Basecamp can't embed https://example.com", outErr.Message) + assert.Equal(t, "Link to it instead", outErr.Hint) +} + +func TestEmbedsCreateWithoutAURLAsksForNothing(t *testing.T) { + transport := &embedTransport{} + app, _ := newTestAppWithTransport(t, transport) + + _, _ = runEmbedCommand(NewEmbedsCmd(), app, "create", " ") + + assert.Empty(t, transport.recordedCalls()) +} + +func TestEmbedsCreateLeavesNoAttachmentForAnEmbedThatIsNotReady(t *testing.T) { + fastEmbedPolling(t, 5*time.Millisecond) + transport := &embedTransport{ + creates: map[string]embedReply{embedVideoURL: {http.StatusCreated, embedJSON(embedVideoURL, "pending", "sgid-video")}}, + gets: map[string][]embedReply{"sgid-video": {{http.StatusOK, embedJSON(embedVideoURL, "pending", "sgid-video")}}}, + } + app, out := newTestAppWithTransport(t, transport) + + _, err := runEmbedCommand(NewEmbedsCmd(), app, "create", embedVideoURL) + require.NoError(t, err) + + assert.NotContains(t, out.String(), `"attachment"`) + assert.Contains(t, out.String(), "basecamp embeds show") +} + +func TestEmbedsShow(t *testing.T) { + transport := &embedTransport{ + gets: map[string][]embedReply{"sgid-video": {{http.StatusOK, embedJSON(embedVideoURL, "active", "sgid-video")}}}, + } + app, out := newTestAppWithTransport(t, transport) + + _, err := runEmbedCommand(NewEmbedsCmd(), app, "show", "sgid-video") + require.NoError(t, err) + + var envelope struct { + Data struct { + Attachment string `json:"attachment"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &envelope)) + assert.Equal(t, `<bc-attachment sgid="sgid-video" content-type="embed/youtube-video"></bc-attachment>`, envelope.Data.Attachment) + assert.Equal(t, []string{"get sgid-video"}, transport.recordedCalls()) +} diff --git a/internal/commands/richtext_format.go b/internal/commands/richtext_format.go index 75c683f44..ea84cdbf4 100644 --- a/internal/commands/richtext_format.go +++ b/internal/commands/richtext_format.go @@ -42,7 +42,7 @@ func (f *richTextFormat) Type() string { return "string" } // addRichTextFormatFlag gives cmd the --format flag richTextToHTML reads. func addRichTextFormatFlag(cmd *cobra.Command) { cmd.Flags().Var(new(richTextFormat), "format", - "Rich-text input format: markdown (converted to HTML) or html (sent without conversion)") + "Rich-text input format: markdown (converted to HTML; a URL alone on its line becomes an embed) or html (sent without conversion)") _ = cmd.RegisterFlagCompletionFunc("format", cobra.FixedCompletions([]string{string(richTextMarkdown), string(richTextHTML)}, cobra.ShellCompDirectiveNoFileComp)) } @@ -68,5 +68,5 @@ func richTextToHTML(cmd *cobra.Command, content string) string { "Supported tags are kept and the Markdown around them is converted. "+ "To send HTML without Markdown conversion, pass --format html.") } - return richtext.MarkdownToHTML(content) + return richtext.MarkdownToHTMLWithEmbeds(content, embedLinks(cmd)) } diff --git a/internal/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index d13c68d5c..e92884a0e 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -71,7 +71,7 @@ func TestCatalogExcludesUnservedOperations(t *testing.T) { assert.False(t, excluded[op.ID], "operation %q should be excluded from the vendored model", op.ID) } } - assert.Equal(t, 279, model, "served model operation count") + assert.Equal(t, 281, model, "served model operation count") assert.Equal(t, 1, composite, "served composite operation count") } diff --git a/internal/mcpserver/domains.go b/internal/mcpserver/domains.go index 339c22e92..5efa8f855 100644 --- a/internal/mcpserver/domains.go +++ b/internal/mcpserver/domains.go @@ -56,8 +56,8 @@ var DomainSpecs = []catalog.DomainSpec{ }, { Key: "files", - Tags: []string{"Files", "Folders"}, - Blurb: "Docs & Files: vaults (folders), documents, uploads, attachments, cloud file links, Google documents, and home-screen project folders.", + Tags: []string{"Files", "Folders", "Embeds"}, + Blurb: "Docs & Files: vaults (folders), documents, uploads, attachments, embeds for rich text (videos, charts), cloud file links, Google documents, and home-screen project folders.", }, { Key: "people", diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 987474f70..03c32f4a5 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "dfe90054e69065000bd3410d6f02435186fe4b1b", - "ref": "v0.26.0", + "commit": "5e6961286780c6d0d8b2335fa1364d8e16d1b585", + "ref": "v0.27.0", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/mcpserver/model/behavior-model.json b/internal/mcpserver/model/behavior-model.json index 98e2f2258..a8bc4d3b9 100644 --- a/internal/mcpserver/model/behavior-model.json +++ b/internal/mcpserver/model/behavior-model.json @@ -174,6 +174,17 @@ ] } }, + "CreateEmbed": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 503 + ] + } + }, "CreateEventBoost": { "retry": { "max": 2, @@ -1050,6 +1061,18 @@ ] } }, + "GetEmbed": { + "readonly": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "GetEverythingCheckins": { "readonly": true, "pagination": { diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index 21f6cc92f..6169b4a06 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -9014,6 +9014,224 @@ } } }, + "/{accountId}/embeds.json": { + "post": { + "description": "Create an embed for a URL, or return the one Basecamp already has for it.\n\nAnswers `200 OK` with the embed either way. A new embed's status is\n`pending`; get it about once a second until its status is `active` or\n`not_found`.\n\nAnswers `422` (`{\"error\": \"Basecamp can't embed this URL. Link to it\ninstead.\"}`) for a URL no provider embeds. Past 100 new embeds in an hour,\ncreating another answers `429` (`{\"error\": \"Too many new embeds. Try again\nlater.\"}`) with no Retry-After; asking for an embed Basecamp already has\nstill works. That 429 is a verdict for the rest of the hour, not\nthrottling, so this operation declares `retryOn: [503]` and the 429\nsurfaces on the first attempt.", + "operationId": "CreateEmbed", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEmbedRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "CreateEmbed 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEmbedResponseContent" + } + } + } + }, + "400": { + "description": "BadRequestError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestErrorResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "422": { + "description": "ValidationError 422 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Embeds" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 503 + ] + } + } + }, + "/{accountId}/embeds/{sgid}": { + "get": { + "description": "Get an embed by its sgid.\n\nAnswers `404 Not Found` for an sgid that does not name an embed.", + "operationId": "GetEmbed", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "sgid", + "in": "path", + "description": "An embed's signed global id, an opaque string.", + "schema": { + "type": "string", + "description": "An embed's signed global id, an opaque string." + }, + "required": true + } + ], + "responses": { + "200": { + "description": "GetEmbed 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetEmbedResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Embeds" + ], + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/events.json": { "get": { "description": "Poll the account event feed for events after a position (oldest first, strict event-id order, up to 100 per page).\n\n**Entry.** With neither `since` nor `position` the feed begins at the present\n(equivalent to `since=now`). `since=<event id>` starts after that id and\n`since=0` replays all served history back to the feed's epoch; `since` is a\nsigned 64-bit integer written in decimal, or the literal `now`. `position`\nresumes from a token a previous page issued \u2014 signed, opaque, bound to the\naccount and the filter set.\n\n**Pagination**: cursor style \u2014 the body envelope, not the Link header.\n`position` is the durable cursor (persist it only after processing the\npage's events); `next` is an absolute continuation URL present only while\nthis walk has more to serve. One call answers one page; no generator emits\na walk for the cursor style \u2014 see the section note.\n\n**Errors.** 400 (FeedRequestError) for a malformed position (resume with\n`since=`) or a malformed filter (fix the filters; a position reset will not\nhelp), told apart by its optional `reason` and undifferentiated when `reason`\nis absent. 409 (FeedFilterMismatchError) when the position was minted for a\ndifferent filter set. 410 (FeedPositionGoneError) when the position predates\nthe feed's epoch; its `resume` re-enters at the epoch.", @@ -15297,7 +15515,7 @@ }, "/{accountId}/projects/{projectId}/people/client_users.json": { "put": { - "description": "Update project client access (grant/revoke/create client users)\n\nThe client-side counterpart to UpdateProjectAccess: `grant` adds existing\nclient users by id, `revoke` removes client users, and `create` invites\nbrand-new clients by email (`name` optional, defaulting to the address).\nOnly client users are eligible \u2014 a `grant` id belonging to a team member is\nrejected (omitted from `granted`) rather than cross-graded, and `revoke` never removes a team\nmember. The response mirrors UpdateProjectAccess: `granted` and `revoked`\npeople, each with `client: true`.\n\nRequires clients to be enabled on the project (EnableProjectClients);\notherwise 403. Invitations are all-or-nothing: an invalid `create` row\n(including one with no email address) answers 422 with the rejected\naddresses and nobody is invited; new addresses that would exceed the\naccount's user limit answer 429 and nobody is invited. Addresses already on\nthe account take no seat and a repeated address counts once.\n\nThe seat-limit 429 is a verdict, not throttling: it carries no Retry-After\nand re-asking cannot change the answer, so this operation declares\n`retryOn: [503]` and a 429 surfaces on the first attempt (status-mapped to\n`rate_limit`, since the wire status is the only signal). Every other\noperation retries on 429.", + "description": "Update project client access (grant/revoke/create client users)\n\nThe client-side counterpart to UpdateProjectAccess: `grant` adds existing\nclient users by id, `revoke` removes client users, and `create` invites\nbrand-new clients by email (`name` optional, defaulting to the address).\nOnly client users are eligible \u2014 a `grant` id belonging to a team member is\nrejected (omitted from `granted`) rather than cross-graded, and `revoke` never removes a team\nmember. The response mirrors UpdateProjectAccess: `granted` and `revoked`\npeople, each with `client: true`.\n\nRequires clients to be enabled on the project (EnableProjectClients);\notherwise 403. Invitations are all-or-nothing: an invalid `create` row\n(including one with no email address) answers 422 with the rejected\naddresses and nobody is invited; new addresses that would exceed the\naccount's user limit answer 429 and nobody is invited. Addresses already on\nthe account take no seat and a repeated address counts once.\n\nThe seat-limit 429 is a verdict, not throttling: it carries no Retry-After\nand re-asking cannot change the answer, so this operation declares\n`retryOn: [503]` and a 429 surfaces on the first attempt (status-mapped to\n`rate_limit`, since the wire status is the only signal). Every other\noperation but CreateEmbed retries on 429.", "operationId": "UpdateProjectClientAccess", "requestBody": { "content": { @@ -31809,6 +32027,21 @@ "CreateDocumentResponseContent": { "$ref": "#/components/schemas/Document" }, + "CreateEmbedRequestContent": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL of the content to embed." + } + }, + "required": [ + "url" + ] + }, + "CreateEmbedResponseContent": { + "$ref": "#/components/schemas/Embed" + }, "CreateEventBoostRequestContent": { "type": "object", "properties": { @@ -32904,6 +33137,45 @@ "title" ] }, + "Embed": { + "type": "object", + "description": "Content from another site, such as a YouTube video, that rich text shows\ninline. Put it in rich text as `<bc-attachment sgid=\"SGID\"></bc-attachment>`\nonce its status is `active`; an embed that is not active shows as a link to\nits URL.", + "properties": { + "url": { + "type": "string" + }, + "status": { + "type": "string", + "description": "`pending` while Basecamp asks the provider about the URL, then `active`,\nor `not_found` when the provider has nothing to embed." + }, + "sgid": { + "type": "string", + "description": "An embed's signed global id, an opaque string." + }, + "type": { + "type": "string", + "description": "The provider's oEmbed type, such as `video`, `rich` or `photo`. Present\nonly when the embed is active." + }, + "content_type": { + "type": "string", + "description": "Such as `embed/youtube-video`. Present only when the embed is active." + }, + "image_url": { + "type": "string", + "description": "Present only when the embed is active, and null when the provider gave no\nimage.", + "nullable": true + }, + "content": { + "type": "string", + "description": "The provider's HTML, sanitized. Present only when the embed is active and\nthe provider gave HTML; may be empty." + } + }, + "required": [ + "sgid", + "status", + "url" + ] + }, "EnableCardColumnOnHoldResponseContent": { "$ref": "#/components/schemas/CardColumn" }, @@ -34007,6 +34279,9 @@ "GetDocumentResponseContent": { "$ref": "#/components/schemas/Document" }, + "GetEmbedResponseContent": { + "$ref": "#/components/schemas/Embed" + }, "GetEverythingCheckinsResponseContent": { "type": "array", "items": { diff --git a/internal/mcpserver/testdata/catalog_snapshot.txt b/internal/mcpserver/testdata/catalog_snapshot.txt index c0d833a68..509ee8e44 100644 --- a/internal/mcpserver/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/testdata/catalog_snapshot.txt @@ -169,7 +169,7 @@ ACTIONS (RO = read-only): - update_timesheet_entry: Update a timesheet entry == basecamp_files -Docs & Files: vaults (folders), documents, uploads, attachments, cloud file links, Google documents, and home-screen project folders. +Docs & Files: vaults (folders), documents, uploads, attachments, embeds for rich text (videos, charts), cloud file links, Google documents, and home-screen project folders. Gateway tool: call with {"action": "...", "params": {...}}. Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema. @@ -177,6 +177,7 @@ Call {"action": "describe", "params": {"action": "NAME"}} for an action's full p ACTIONS (RO = read-only): - create_cloud_file: Create a new cloud file in a vault - create_document: Create a new document in a vault +- create_embed: Create an embed for a URL, or return the one Basecamp already has for it - create_folder: Create a folder for the authenticated user and file the given projects into it - create_google_document: Create a new Google document in a vault - create_upload: Create a new upload in a vault @@ -185,6 +186,7 @@ ACTIONS (RO = read-only): - delete_folder: Delete a folder and unpin its projects from the home screen (returns 204 No Content) - get_cloud_file (RO): Get a single cloud file by id - get_document (RO): Get a single document by id +- get_embed (RO): Get an embed by its sgid - get_folder (RO): Get one folder, with the projects grouped inside it expanded under `projects` - get_google_document (RO): Get a single Google document by id - get_upload (RO): Get a single upload by id diff --git a/internal/resilience/hooks_test.go b/internal/resilience/hooks_test.go index 5255c1f1d..105633a89 100644 --- a/internal/resilience/hooks_test.go +++ b/internal/resilience/hooks_test.go @@ -278,6 +278,7 @@ func TestGatingHooksHeaderless429BlocksUnlessTheOperationTreatsItAsAVerdict(t *t assert.Greater(t, block("UpdateProjectAccess"), 50*time.Second, "throttling 429 keeps the default block") assert.Greater(t, block(""), 50*time.Second, "an unnamed operation keeps the default block") assert.Zero(t, block("UpdateProjectClientAccess"), "a seat-limit verdict sets no block") + assert.Zero(t, block("CreateEmbed"), "the hourly new-embed limit sets no block") } // A 429 that names a Retry-After is honored regardless of the operation. diff --git a/internal/richtext/embeds.go b/internal/richtext/embeds.go new file mode 100644 index 000000000..3c3a98411 --- /dev/null +++ b/internal/richtext/embeds.go @@ -0,0 +1,219 @@ +package richtext + +import ( + "bytes" + "html" + "regexp" + "slices" + "strings" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type EmbedRef struct { + SGID string + ContentType string +} + +type EmbedFunc func(urls []string) map[string]EmbedRef + +type EmbedAttachment struct { + ast.BaseBlock + Ref EmbedRef +} + +var KindEmbedAttachment = ast.NewNodeKind("EmbedAttachment") + +func (n *EmbedAttachment) Kind() ast.NodeKind { return KindEmbedAttachment } +func (n *EmbedAttachment) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +func EmbedToHTML(ref EmbedRef) string { + tag := `<bc-attachment sgid="` + escapeAttr(ref.SGID) + `"` + if ref.ContentType != "" { + tag += ` content-type="` + escapeAttr(ref.ContentType) + `"` + } + return tag + `></bc-attachment>` +} + +func MarkdownToHTMLWithEmbeds(md string, embed EmbedFunc) string { + if md == "" { + return "" + } + + md = strings.ReplaceAll(md, "\r\n", "\n") + md = strings.ReplaceAll(md, "\r", "\n") + source := []byte(md) + + doc := mdConverter.Parser().Parse(text.NewReader(source)) + if embed != nil { + embedStandaloneURLs(doc, source, embed) + } + + var buf bytes.Buffer + if err := mdConverter.Renderer().Render(&buf, source, doc); err != nil { + return "<p>" + html.EscapeString(md) + "</p>" + } + return strings.TrimSpace(buf.String()) +} + +var ( + reStandaloneURL = regexp.MustCompile(`^(?i:https?)://[^\s<>"'\\]+$`) + reCharacterReference = regexp.MustCompile(`&(#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);`) +) + +func embedStandaloneURLs(doc ast.Node, source []byte, embed EmbedFunc) { + paragraphs := topLevelParagraphs(doc) + + var urls []string + for _, p := range paragraphs { + for _, line := range paragraphLines(p) { + if url := standaloneURL(line, source); url != "" && !slices.Contains(urls, url) { + urls = append(urls, url) + } + } + } + if len(urls) == 0 { + return + } + + refs := embed(urls) + if len(refs) == 0 { + return + } + + for _, p := range paragraphs { + splitParagraphAtEmbeds(p, source, refs) + } +} + +func topLevelParagraphs(doc ast.Node) []*ast.Paragraph { + var paragraphs []*ast.Paragraph + for child := doc.FirstChild(); child != nil; child = child.NextSibling() { + if p, ok := child.(*ast.Paragraph); ok && !holdsInlineHTML(p) { + paragraphs = append(paragraphs, p) + } + } + return paragraphs +} + +func holdsInlineHTML(p *ast.Paragraph) bool { + found := false + _ = ast.Walk(p, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if _, ok := n.(*ast.RawHTML); ok && entering { + found = true + return ast.WalkStop, nil + } + return ast.WalkContinue, nil + }) + return found +} + +func paragraphLines(p *ast.Paragraph) [][]ast.Node { + var lines [][]ast.Node + var line []ast.Node + for child := p.FirstChild(); child != nil; child = child.NextSibling() { + line = append(line, child) + if t, ok := child.(*ast.Text); ok && (t.SoftLineBreak() || t.HardLineBreak()) { + lines = append(lines, line) + line = nil + } + } + if len(line) > 0 { + lines = append(lines, line) + } + return lines +} + +func standaloneURL(line []ast.Node, source []byte) string { + var content []ast.Node + for _, n := range line { + if t, ok := n.(*ast.Text); ok && len(bytes.TrimSpace(t.Segment.Value(source))) == 0 { + continue + } + content = append(content, n) + } + + if len(content) == 1 { + if link, ok := content[0].(*ast.AutoLink); ok && link.AutoLinkType == ast.AutoLinkURL { + return embeddableURL(string(link.URL(source))) + } + } + + var raw []byte + for _, n := range content { + t, ok := n.(*ast.Text) + if !ok || t.IsRaw() { + return "" + } + raw = append(raw, t.Segment.Value(source)...) + } + return embeddableURL(string(bytes.TrimSpace(raw))) +} + +func embeddableURL(url string) string { + if reStandaloneURL.MatchString(url) && !reCharacterReference.MatchString(url) { + return url + } + return "" +} + +func splitParagraphAtEmbeds(p *ast.Paragraph, source []byte, refs map[string]EmbedRef) { + lines := paragraphLines(p) + embeds := make([]*EmbedRef, len(lines)) + embedded := false + for i, line := range lines { + if ref, ok := refs[standaloneURL(line, source)]; ok { + embeds[i] = &ref + embedded = true + } + } + if !embedded { + return + } + + parent := p.Parent() + var pending *ast.Paragraph + flush := func() { + if pending != nil { + trimTrailingBreak(pending) + parent.InsertBefore(parent, p, pending) + pending = nil + } + } + for i, line := range lines { + if embeds[i] != nil { + flush() + parent.InsertBefore(parent, p, &EmbedAttachment{Ref: *embeds[i]}) + continue + } + if pending == nil { + pending = ast.NewParagraph() + } + for _, n := range line { + pending.AppendChild(pending, n) + } + } + flush() + parent.RemoveChild(parent, p) +} + +func trimTrailingBreak(p *ast.Paragraph) { + if t, ok := p.LastChild().(*ast.Text); ok { + t.SetSoftLineBreak(false) + t.SetHardLineBreak(false) + } +} + +func (r *trixRenderer) renderEmbedAttachment(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + if n, ok := node.(*EmbedAttachment); ok { + _, _ = w.WriteString(EmbedToHTML(n.Ref) + "\n") + } + return ast.WalkContinue, nil +} diff --git a/internal/richtext/embeds_test.go b/internal/richtext/embeds_test.go new file mode 100644 index 000000000..bd81de70f --- /dev/null +++ b/internal/richtext/embeds_test.go @@ -0,0 +1,152 @@ +package richtext + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + videoURL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + chartURL = "https://dash.37signals.com/widgets/abc" +) + +func embedAll(seen *[]string) EmbedFunc { + return func(urls []string) map[string]EmbedRef { + *seen = append(*seen, urls...) + refs := map[string]EmbedRef{} + for _, url := range urls { + refs[url] = EmbedRef{SGID: "sgid-" + url[len(url)-3:], ContentType: "embed/test"} + } + return refs + } +} + +func TestMarkdownToHTMLWithEmbedsEmbedsAURLAloneOnItsLine(t *testing.T) { + var seen []string + got := MarkdownToHTMLWithEmbeds(videoURL, embedAll(&seen)) + + assert.Equal(t, []string{videoURL}, seen) + assert.Equal(t, `<bc-attachment sgid="sgid-XcQ" content-type="embed/test"></bc-attachment>`, got) +} + +func TestMarkdownToHTMLWithEmbedsSplitsAParagraphAroundEmbeddedLines(t *testing.T) { + var seen []string + md := "Charts for the incident:\n" + chartURL + "\n" + videoURL + "\nThat's all." + got := MarkdownToHTMLWithEmbeds(md, embedAll(&seen)) + + assert.Equal(t, []string{chartURL, videoURL}, seen) + assert.Equal(t, "<p>Charts for the incident:</p>\n"+ + `<bc-attachment sgid="sgid-abc" content-type="embed/test"></bc-attachment>`+"\n"+ + `<bc-attachment sgid="sgid-XcQ" content-type="embed/test"></bc-attachment>`+"\n"+ + "<p>That's all.</p>", got) +} + +func TestMarkdownToHTMLWithEmbedsKeepsBlankLineSpacing(t *testing.T) { + var seen []string + got := MarkdownToHTMLWithEmbeds("Intro\n\n"+videoURL+"\n\nOutro", embedAll(&seen)) + + assert.Equal(t, "<p>Intro</p>\n<p><br></p>\n"+ + `<bc-attachment sgid="sgid-XcQ" content-type="embed/test"></bc-attachment>`+"\n"+ + "<p><br></p>\n<p>Outro</p>", got) +} + +func TestMarkdownToHTMLWithEmbedsEmbedsAnAutolinkAloneOnItsLine(t *testing.T) { + var seen []string + got := MarkdownToHTMLWithEmbeds("<"+videoURL+">\nnext", embedAll(&seen)) + + assert.Equal(t, []string{videoURL}, seen) + assert.Equal(t, `<bc-attachment sgid="sgid-XcQ" content-type="embed/test"></bc-attachment>`+"\n<p>next</p>", got) +} + +func TestMarkdownToHTMLWithEmbedsAsksOncePerURL(t *testing.T) { + var seen []string + got := MarkdownToHTMLWithEmbeds(videoURL+"\n\n"+videoURL, embedAll(&seen)) + + assert.Equal(t, []string{videoURL}, seen) + assert.Equal(t, `<bc-attachment sgid="sgid-XcQ" content-type="embed/test"></bc-attachment>`+"\n<p><br></p>\n"+ + `<bc-attachment sgid="sgid-XcQ" content-type="embed/test"></bc-attachment>`, got) +} + +func TestMarkdownToHTMLWithEmbedsLeavesURLsItCanNotEmbed(t *testing.T) { + refuse := func(urls []string) map[string]EmbedRef { return nil } + md := "Intro\n" + videoURL + "\nOutro" + + assert.Equal(t, MarkdownToHTML(md), MarkdownToHTMLWithEmbeds(md, refuse)) +} + +func TestMarkdownToHTMLWithEmbedsEmbedsOnlyTheURLsItWasGiven(t *testing.T) { + onlyChart := func(urls []string) map[string]EmbedRef { + return map[string]EmbedRef{chartURL: {SGID: "chart"}} + } + got := MarkdownToHTMLWithEmbeds(videoURL+"\n"+chartURL, onlyChart) + + assert.Equal(t, "<p>"+videoURL+"</p>\n"+`<bc-attachment sgid="chart"></bc-attachment>`, got) +} + +func TestMarkdownToHTMLWithEmbedsLeavesEverythingButAURLAloneOnALine(t *testing.T) { + for name, md := range map[string]string{ + "in a sentence": "Watch " + videoURL + " today", + "a titled link": "[The video](" + videoURL + ")", + "in a list": "- " + videoURL, + "in a numbered list": "1. " + videoURL, + "in a quote": "> " + videoURL, + "in a code span": "`" + videoURL + "`", + "in a code block": "```\n" + videoURL + "\n```", + "in an indented block": " " + videoURL, + "in an HTML block": "<p>\n" + videoURL + "\n</p>", + "in a heading": "# " + videoURL, + "emphasized": "*" + videoURL + "*", + "with an escape": videoURL + `\_x`, + "another scheme": "ftp://example.com/video", + "javascript": "javascript:alert(1)", + "a mail autolink": "<someone@example.com>", + "with markup in it": "https://www.youtube.com/watch?v=<b>x</b>", + "two URLs on one line": videoURL + " " + chartURL, + "in a table": "| a |\n|---|\n| " + videoURL + " |", + "followed by text": videoURL + " is the one", + "after a bc-attachment": `<bc-attachment sgid="x"></bc-attachment> ` + videoURL, + "inside inline HTML": "Intro <span>\n" + videoURL + "\n</span>", + "under inline HTML": "<b>Watch</b>\n" + videoURL, + "under nested HTML": "**<span>Intro</span>**\n" + videoURL, + "with an entity": "https://www.youtube.com/watch?v=x&t=1", + "with a number entity": "https://www.youtube.com/watch?v=x&t=1", + "autolinked entity": "<https://www.youtube.com/watch?v=x&t=1>", + } { + t.Run(name, func(t *testing.T) { + var seen []string + got := MarkdownToHTMLWithEmbeds(md, embedAll(&seen)) + + assert.Empty(t, seen) + assert.Equal(t, MarkdownToHTML(md), got) + }) + } +} + +func TestMarkdownToHTMLWithEmbedsKeepsAQueryWithABareAmpersand(t *testing.T) { + var seen []string + MarkdownToHTMLWithEmbeds("https://www.youtube.com/watch?v=x&t=1", embedAll(&seen)) + + assert.Equal(t, []string{"https://www.youtube.com/watch?v=x&t=1"}, seen) +} + +func TestMarkdownToHTMLWithEmbedsReadsTheSchemeInAnyCase(t *testing.T) { + var seen []string + MarkdownToHTMLWithEmbeds("HTTPS://www.youtube.com/watch?v=x", embedAll(&seen)) + + assert.Equal(t, []string{"HTTPS://www.youtube.com/watch?v=x"}, seen) +} + +func TestMarkdownToHTMLWithEmbedsWithoutAnEmbedFuncIsMarkdownToHTML(t *testing.T) { + md := "Intro\n" + videoURL + "\n\n- item" + + assert.Equal(t, MarkdownToHTML(md), MarkdownToHTMLWithEmbeds(md, nil)) + assert.Equal(t, "<p>Intro\n"+videoURL+"</p>\n<p><br></p>\n<ul>\n<li>item</li>\n</ul>", MarkdownToHTML(md)) +} + +func TestEmbedToHTMLEscapesItsAttributes(t *testing.T) { + assert.Equal(t, `<bc-attachment sgid="a"><script>"></bc-attachment>`, + EmbedToHTML(EmbedRef{SGID: `a"><script>`})) + assert.Equal(t, `<bc-attachment sgid="s" content-type="embed/"x"></bc-attachment>`, + EmbedToHTML(EmbedRef{SGID: "s", ContentType: `embed/"x`})) +} diff --git a/internal/richtext/richtext.go b/internal/richtext/richtext.go index 8d5432d1b..bd62cda99 100644 --- a/internal/richtext/richtext.go +++ b/internal/richtext/richtext.go @@ -3,7 +3,6 @@ package richtext import ( - "bytes" "errors" "fmt" "html" @@ -350,6 +349,7 @@ func (r *trixRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock) reg.Register(KindTrixBreak, r.renderTrixBreak) reg.Register(KindEscapedAt, r.renderEscapedAt) + reg.Register(KindEmbedAttachment, r.renderEmbedAttachment) } func (r *trixRenderer) renderBlockquote(w util.BufWriter, _ []byte, _ ast.Node, entering bool) (ast.WalkStatus, error) { @@ -479,19 +479,7 @@ func (r *trixRenderer) renderEscapedAt(w util.BufWriter, _ []byte, _ ast.Node, e // is not converted, as CommonMark specifies; a blank line ends the block. // Callers that want HTML sent exactly as written skip this function. func MarkdownToHTML(md string) string { - if md == "" { - return "" - } - - md = strings.ReplaceAll(md, "\r\n", "\n") - md = strings.ReplaceAll(md, "\r", "\n") - - var buf bytes.Buffer - if err := mdConverter.Convert([]byte(md), &buf); err != nil { - return "<p>" + html.EscapeString(md) + "</p>" - } - - return strings.TrimSpace(buf.String()) + return MarkdownToHTMLWithEmbeds(md, nil) } // HasRichTextHTML reports whether MarkdownToHTML would keep any raw HTML from diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index fd3395d2b..10525c72d 100644 --- a/internal/version/sdk-provenance.json +++ b/internal/version/sdk-provenance.json @@ -1,9 +1,9 @@ { "sdk": { "module": "github.com/basecamp/basecamp-sdk/go", - "version": "v0.26.0", - "revision": "dfe90054e690", - "updated_at": "2026-10-20T00:38:27Z" + "version": "v0.27.0", + "revision": "5e6961286780", + "updated_at": "2026-10-21T16:35:15Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index b47f10d95..d143cbff0 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -8,7 +8,7 @@ buildGoModule.override { go = go_1_26; } (finalAttrs: { src = lib.cleanSource ./..; # To update: set to lib.fakeHash, run `nix build`, use the hash from the error. - vendorHash = "sha256-l/kddGGXlnJXenhyAj5xRpEy0bJA+gTFFNreq+KY4kA="; + vendorHash = "sha256-IeUWPtO52gtx8YwSwMXUWWyMMGC1NwoRurcNafM2ZSo="; subPackages = [ "cmd/basecamp" ]; diff --git a/skill-evals/cases/create-card-embed.yml b/skill-evals/cases/create-card-embed.yml new file mode 100644 index 000000000..f967f2aa4 --- /dev/null +++ b/skill-evals/cases/create-card-embed.yml @@ -0,0 +1,10 @@ +task: Create a card titled "Checkout latency" in project 55555 that shows the Dash chart https://dash.37signals.com/widgets/abc in the card itself, not as a link +tags: [domain, create] +accept: + - 'cards? create .*Checkout latency' + - '--(in|project) 55555' + - '(?m)(^|["''])https://dash\.37signals\.com/widgets/abc\s*(["'']|$)' +reject: + - '\[[^\]]*\]\(https://dash\.37signals\.com/widgets/abc\)' + - '--format html' + - 'basecamp api' diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 2f651c138..8b6ac48af 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -253,6 +253,23 @@ flowchart LR EOF ```` +To embed a video, a Dash chart, or anything else Basecamp's editor embeds when +a link is pasted, put the URL on a line by itself in Markdown content. The CLI +asks Basecamp for the embed and waits for it. A URL Basecamp can't embed stays +a link, and so does a URL inside a sentence, a list, a quote, or code. To keep +a link to something Basecamp could embed, write `[title](url)`. + +```bash +basecamp cards create "Checkout latency" - --in "<project>" --json <<'EOF' +p99 since the deploy: + +https://dash.37signals.com/widgets/abc +EOF +``` + +With `--format html` nothing is embedded for you: run +`basecamp embeds create <url> --json` and put its `attachment` tag in the HTML. + For deterministic mentions, prefer the machine-provided mention syntax from people or comment results, for example `[@Name](mention:SGID)`. Mention support varies by field; leaf help is authoritative.