Skip to content

Commit 112e6fd

Browse files
committed
feat(chat): add chat update command for editing campfire messages
Adds 'basecamp chat update <id|url> [content]' for editing existing campfire lines in place via PUT /chats/{c}/lines/{l}. Mirrors 'chat post' shape: positional content or --content flag, --content-type for HTML, @mention resolution with auto-promotion to text/html. Backed by basecamp-sdk CampfiresService.UpdateLine. While the SDK addition is in flight, go.mod has a temporary replace pinning to a fork branch carrying just that operation; the replace is dropped once an SDK release with UpdateLine ships. Wires up surface snapshot, smoke + e2e coverage, and the basecamp skill documentation.
1 parent 318e702 commit 112e6fd

8 files changed

Lines changed: 409 additions & 2 deletions

File tree

.surface

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ ARG basecamp campfire delete 00 <id|url>
3030
ARG basecamp campfire line 00 <id|url>
3131
ARG basecamp campfire post 00 <message>
3232
ARG basecamp campfire show 00 <id|url>
33+
ARG basecamp campfire update 00 <id|url>
34+
ARG basecamp campfire update 01 [content]
3335
ARG basecamp campfire upload 00 <file>
3436
ARG basecamp card 00 <title>
3537
ARG basecamp card 01 [body]
@@ -66,6 +68,8 @@ ARG basecamp chat delete 00 <id|url>
6668
ARG basecamp chat line 00 <id|url>
6769
ARG basecamp chat post 00 <message>
6870
ARG basecamp chat show 00 <id|url>
71+
ARG basecamp chat update 00 <id|url>
72+
ARG basecamp chat update 01 [content]
6973
ARG basecamp chat upload 00 <file>
7074
ARG basecamp checkin answer 00 <id|url>
7175
ARG basecamp checkin answer create 00 <question-id>
@@ -479,6 +483,7 @@ CMD basecamp campfire list
479483
CMD basecamp campfire messages
480484
CMD basecamp campfire post
481485
CMD basecamp campfire show
486+
CMD basecamp campfire update
482487
CMD basecamp campfire upload
483488
CMD basecamp card
484489
CMD basecamp card move
@@ -520,6 +525,7 @@ CMD basecamp chat list
520525
CMD basecamp chat messages
521526
CMD basecamp chat post
522527
CMD basecamp chat show
528+
CMD basecamp chat update
523529
CMD basecamp chat upload
524530
CMD basecamp checkin
525531
CMD basecamp checkin answer
@@ -2363,6 +2369,30 @@ FLAG basecamp campfire show --stats type=bool
23632369
FLAG basecamp campfire show --styled type=bool
23642370
FLAG basecamp campfire show --todolist type=string
23652371
FLAG basecamp campfire show --verbose type=count
2372+
FLAG basecamp campfire update --account type=string
2373+
FLAG basecamp campfire update --agent type=bool
2374+
FLAG basecamp campfire update --cache-dir type=string
2375+
FLAG basecamp campfire update --content type=string
2376+
FLAG basecamp campfire update --content-type type=string
2377+
FLAG basecamp campfire update --count type=bool
2378+
FLAG basecamp campfire update --help type=bool
2379+
FLAG basecamp campfire update --hints type=bool
2380+
FLAG basecamp campfire update --ids-only type=bool
2381+
FLAG basecamp campfire update --in type=string
2382+
FLAG basecamp campfire update --jq type=string
2383+
FLAG basecamp campfire update --json type=bool
2384+
FLAG basecamp campfire update --markdown type=bool
2385+
FLAG basecamp campfire update --md type=bool
2386+
FLAG basecamp campfire update --no-hints type=bool
2387+
FLAG basecamp campfire update --no-stats type=bool
2388+
FLAG basecamp campfire update --profile type=string
2389+
FLAG basecamp campfire update --project type=string
2390+
FLAG basecamp campfire update --quiet type=bool
2391+
FLAG basecamp campfire update --room type=string
2392+
FLAG basecamp campfire update --stats type=bool
2393+
FLAG basecamp campfire update --styled type=bool
2394+
FLAG basecamp campfire update --todolist type=string
2395+
FLAG basecamp campfire update --verbose type=count
23662396
FLAG basecamp campfire upload --account type=string
23672397
FLAG basecamp campfire upload --agent type=bool
23682398
FLAG basecamp campfire upload --cache-dir type=string
@@ -3336,6 +3366,30 @@ FLAG basecamp chat show --stats type=bool
33363366
FLAG basecamp chat show --styled type=bool
33373367
FLAG basecamp chat show --todolist type=string
33383368
FLAG basecamp chat show --verbose type=count
3369+
FLAG basecamp chat update --account type=string
3370+
FLAG basecamp chat update --agent type=bool
3371+
FLAG basecamp chat update --cache-dir type=string
3372+
FLAG basecamp chat update --content type=string
3373+
FLAG basecamp chat update --content-type type=string
3374+
FLAG basecamp chat update --count type=bool
3375+
FLAG basecamp chat update --help type=bool
3376+
FLAG basecamp chat update --hints type=bool
3377+
FLAG basecamp chat update --ids-only type=bool
3378+
FLAG basecamp chat update --in type=string
3379+
FLAG basecamp chat update --jq type=string
3380+
FLAG basecamp chat update --json type=bool
3381+
FLAG basecamp chat update --markdown type=bool
3382+
FLAG basecamp chat update --md type=bool
3383+
FLAG basecamp chat update --no-hints type=bool
3384+
FLAG basecamp chat update --no-stats type=bool
3385+
FLAG basecamp chat update --profile type=string
3386+
FLAG basecamp chat update --project type=string
3387+
FLAG basecamp chat update --quiet type=bool
3388+
FLAG basecamp chat update --room type=string
3389+
FLAG basecamp chat update --stats type=bool
3390+
FLAG basecamp chat update --styled type=bool
3391+
FLAG basecamp chat update --todolist type=string
3392+
FLAG basecamp chat update --verbose type=count
33393393
FLAG basecamp chat upload --account type=string
33403394
FLAG basecamp chat upload --agent type=bool
33413395
FLAG basecamp chat upload --cache-dir type=string
@@ -16266,6 +16320,7 @@ SUB basecamp campfire list
1626616320
SUB basecamp campfire messages
1626716321
SUB basecamp campfire post
1626816322
SUB basecamp campfire show
16323+
SUB basecamp campfire update
1626916324
SUB basecamp campfire upload
1627016325
SUB basecamp card
1627116326
SUB basecamp card move
@@ -16307,6 +16362,7 @@ SUB basecamp chat list
1630716362
SUB basecamp chat messages
1630816363
SUB basecamp chat post
1630916364
SUB basecamp chat show
16365+
SUB basecamp chat update
1631016366
SUB basecamp chat upload
1631116367
SUB basecamp checkin
1631216368
SUB basecamp checkin answer

API-COVERAGE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ The **Since** column tags each row with the Basecamp version that introduced its
3737
| messages | 10 | `messages`, `message` || BC4 | - | list, show, create, update, publish, pin, unpin. Create supports `--subscribe`/`--no-subscribe` and `--draft`. Publish promotes drafts to active |
3838
| message_boards | 3 | `messageboards` || BC4 | - | Container, accessed via project dock |
3939
| message_types | 5 | `messagetypes` || BC4 | - | list, show, create, update, delete |
40-
| campfires | 14 | `chat` || BC4 | - | list, messages, post, line show/delete. @mentions in content |
40+
| campfires | 14 | `chat` || BC4 | - | list, messages, post, line show/update/delete. @mentions in content |
4141
| comments | 8 | `comment`, `comments` || BC4 | - | list, show, create, update. @mentions in content |
4242
| boosts | 6 | `boost`, `react` || BC4 | - | list (recording + event), show, create (recording + event), delete |
4343
| notifications | 2 | `notifications` || BC4 | - | list, mark as read |

e2e/chat.bats

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,15 @@ load test_helper
9595
assert_output_contains "ID required"
9696
}
9797

98+
@test "chat update without args shows error" {
99+
create_credentials
100+
create_global_config '{"account_id": 99999, "project_id": 123}'
101+
102+
run basecamp chat update
103+
assert_failure
104+
assert_output_contains "required"
105+
}
106+
98107

99108
# Help flag
100109

e2e/smoke/smoke_campfire.bats

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,28 @@ setup_file() {
4444
assert_json_not_null '.data.id'
4545
}
4646

47+
@test "campfire update edits a message" {
48+
local id_file="$BATS_FILE_TMPDIR/campfire_line_id"
49+
[[ -f "$id_file" ]] || mark_unverifiable "No campfire line created in prior test"
50+
local line_id new_content
51+
line_id=$(<"$id_file")
52+
new_content="Edited smoke test $(date +%s)"
53+
54+
run_smoke basecamp campfire update "$line_id" "$new_content" \
55+
--room "$QA_CAMPFIRE" -p "$QA_PROJECT" --json
56+
assert_success
57+
assert_json_value '.ok' 'true'
58+
assert_json_not_null '.data.id'
59+
60+
# Re-fetch the line and verify its content actually changed (guards against
61+
# a no-op update silently passing).
62+
run_smoke basecamp campfire line "$line_id" \
63+
--room "$QA_CAMPFIRE" -p "$QA_PROJECT" --json
64+
assert_success
65+
echo "$output" | jq -e --arg expected "$new_content" '.data.content | contains($expected)' >/dev/null \
66+
|| fail "expected updated line content to contain '$new_content', got: $(echo "$output" | jq -r '.data.content')"
67+
}
68+
4769
@test "campfire delete deletes a message" {
4870
local id_file="$BATS_FILE_TMPDIR/campfire_line_id"
4971
[[ -f "$id_file" ]] || mark_unverifiable "No campfire line created in prior test"

internal/commands/chat.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66
"path/filepath"
7+
"regexp"
78
"slices"
89
"strconv"
910
"strings"
@@ -17,6 +18,24 @@ import (
1718
"github.com/basecamp/basecamp-cli/internal/tui"
1819
)
1920

21+
// chatURLRe matches both forms of Basecamp chat-line URLs and captures
22+
// the chat (campfire) ID alongside the line ID:
23+
//
24+
// .../chats/{chatID}/lines/{lineID}
25+
// .../chats/{chatID}@{lineID}
26+
var chatURLRe = regexp.MustCompile(`/chats/(\d+)(?:/lines/|@)(\d+)`)
27+
28+
// extractChatLineFromURL pulls the chat (campfire) ID from a chat-line URL
29+
// when present. Returns ("", "") if arg is not a chat-line URL — callers fall
30+
// back to --room and the project's default chat in that case.
31+
func extractChatLineFromURL(arg string) (chatID, lineID string) {
32+
m := chatURLRe.FindStringSubmatch(arg)
33+
if m == nil {
34+
return "", ""
35+
}
36+
return m[1], m[2]
37+
}
38+
2039
// NewChatCmd creates the chat command for real-time chat.
2140
func NewChatCmd() *cobra.Command {
2241
var project string
@@ -44,6 +63,7 @@ Use 'basecamp chat post "message"' to post a message.`,
4463
newChatPostCmd(&project, &chatID, &contentType),
4564
newChatUploadCmd(&project, &chatID),
4665
newChatLineShowCmd(&project, &chatID),
66+
newChatLineUpdateCmd(&project, &chatID, &contentType),
4767
newChatLineDeleteCmd(&project, &chatID),
4868
)
4969

@@ -733,6 +753,150 @@ You can pass either a line ID or a Basecamp line URL:
733753
return cmd
734754
}
735755

756+
func newChatLineUpdateCmd(project, chatID, contentType *string) *cobra.Command {
757+
var content string
758+
759+
cmd := &cobra.Command{
760+
Use: "update <id|url> [content]",
761+
Short: "Update an existing message",
762+
Long: `Update the content of an existing chat message.
763+
764+
You can pass either a line ID or a Basecamp line URL:
765+
basecamp chat update 789 "edited message" --in my-project
766+
basecamp chat update https://3.basecamp.com/123/buckets/456/chats/789/lines/111 --content "edited"
767+
768+
By default, content is sent as plain text. Use --content-type text/html
769+
for rich text. @mentions resolve like 'chat post' and promote to text/html
770+
when present.`,
771+
Args: cobra.RangeArgs(1, 2),
772+
RunE: func(cmd *cobra.Command, args []string) error {
773+
app := appctx.FromContext(cmd.Context())
774+
775+
messageContent := content
776+
if len(args) > 1 {
777+
messageContent = args[1]
778+
}
779+
780+
if strings.TrimSpace(messageContent) == "" {
781+
return missingArg(cmd, "<content>")
782+
}
783+
784+
if err := ensureAccount(cmd, app); err != nil {
785+
return err
786+
}
787+
788+
lineID, urlProjectID := extractWithProject(args[0])
789+
790+
// Pull the chat (campfire) ID from the URL when the user pasted a chat-line URL.
791+
// In multi-room projects, falling back to --room or the dock default would
792+
// hit the wrong room and 404.
793+
urlChatID, urlLineID := extractChatLineFromURL(args[0])
794+
if urlLineID != "" {
795+
lineID = urlLineID
796+
}
797+
798+
projectID := *project
799+
if projectID == "" && urlProjectID != "" {
800+
projectID = urlProjectID
801+
}
802+
if projectID == "" {
803+
projectID = app.Flags.Project
804+
}
805+
if projectID == "" {
806+
projectID = app.Config.ProjectID
807+
}
808+
if projectID == "" {
809+
if err := ensureProject(cmd, app); err != nil {
810+
return err
811+
}
812+
projectID = app.Config.ProjectID
813+
}
814+
815+
resolvedProjectID, _, err := app.Names.ResolveProject(cmd.Context(), projectID)
816+
if err != nil {
817+
return err
818+
}
819+
820+
effectiveChatID := *chatID
821+
if effectiveChatID == "" && urlChatID != "" {
822+
effectiveChatID = urlChatID
823+
}
824+
if effectiveChatID == "" {
825+
effectiveChatID, err = getChatID(cmd, app, resolvedProjectID)
826+
if err != nil {
827+
return err
828+
}
829+
}
830+
831+
chatIDInt, err := strconv.ParseInt(effectiveChatID, 10, 64)
832+
if err != nil {
833+
return output.ErrUsage("Invalid chat room ID")
834+
}
835+
lineIDInt, err := strconv.ParseInt(lineID, 10, 64)
836+
if err != nil {
837+
return output.ErrUsage("Invalid line ID")
838+
}
839+
840+
// Resolve @mentions — same flow as chat post.
841+
ct := *contentType
842+
var mentionNotice string
843+
if ct == "" || ct == "text/html" {
844+
mentionInput := messageContent
845+
if ct == "" {
846+
mentionInput = richtext.MarkdownToHTML(messageContent)
847+
}
848+
result, resolveErr := resolveMentions(cmd.Context(), app.Names, mentionInput)
849+
if resolveErr != nil {
850+
return resolveErr
851+
}
852+
if result.HTML != mentionInput || len(result.Unresolved) > 0 {
853+
messageContent = result.HTML
854+
if ct == "" {
855+
ct = "text/html"
856+
}
857+
}
858+
mentionNotice = unresolvedMentionWarning(result.Unresolved)
859+
}
860+
861+
var opts *basecamp.UpdateLineOptions
862+
if ct != "" {
863+
opts = &basecamp.UpdateLineOptions{ContentType: ct}
864+
}
865+
line, err := app.Account().Campfires().UpdateLine(cmd.Context(), chatIDInt, lineIDInt, messageContent, opts)
866+
if err != nil {
867+
return convertSDKError(err)
868+
}
869+
870+
respOpts := []output.ResponseOption{
871+
output.WithSummary(fmt.Sprintf("Updated line #%s", lineID)),
872+
output.WithEntity("chat_line"),
873+
output.WithDisplayData(chatLineDisplayData(line)),
874+
output.WithBreadcrumbs(
875+
output.Breadcrumb{
876+
Action: "show",
877+
Cmd: fmt.Sprintf("basecamp chat line %s --room %s --in %s", lineID, effectiveChatID, resolvedProjectID),
878+
Description: "View line",
879+
},
880+
output.Breadcrumb{
881+
Action: "messages",
882+
Cmd: fmt.Sprintf("basecamp chat messages --room %s --in %s", effectiveChatID, resolvedProjectID),
883+
Description: "Back to messages",
884+
},
885+
),
886+
}
887+
if mentionNotice != "" {
888+
respOpts = append(respOpts, output.WithDiagnostic(mentionNotice))
889+
}
890+
return app.OK(line, respOpts...)
891+
},
892+
}
893+
894+
cmd.Flags().StringVar(&content, "content", "", "New message content")
895+
cmd.Flags().StringVar(contentType, "content-type", "", "Content type (text/html for rich text)")
896+
897+
return cmd
898+
}
899+
736900
func newChatLineDeleteCmd(project, chatID *string) *cobra.Command {
737901
var force bool
738902

0 commit comments

Comments
 (0)