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.
2140func 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+
736900func newChatLineDeleteCmd (project , chatID * string ) * cobra.Command {
737901 var force bool
738902
0 commit comments