From 6891f3febc4b9dd8a5485dda70b1d26be289857e Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sat, 29 Aug 2026 16:36:54 -0500 Subject: [PATCH] feat: private-message send via POST /posts.json targets Client.SendMessage(title, raw, targets) creates a private-message topic by posting to /posts.json with target_usernames / target_group_names / target_emails (comma-joined on the wire, as Discourse expects, never JSON arrays) instead of a category. The result reuses the topic ids type, so URL() renders the PM link. Local validation requires title, body and at least one target; undeliverable recipients surface as the typed ErrForbidden class. Fake-server tests assert the wire shape and the recorded recipients. Part of Redmine 507 (Discourse Go client): PM delivery is how the 495 pipeline pings humans that a briefing has landed. --- discourse_test.go | 63 ++++++++++++++++++++++++++++++- messages.go | 77 +++++++++++++++++++++++++++++++++++++ messages_test.go | 96 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 messages.go create mode 100644 messages_test.go diff --git a/discourse_test.go b/discourse_test.go index 77cc7d3..221e341 100644 --- a/discourse_test.go +++ b/discourse_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -28,6 +29,8 @@ type fakeDiscourse struct { topics map[int]topicRec nextT int searchFail int + pms []pmRec // private messages, in creation order + lastBody string // raw JSON body of the last POST /posts.json srv *httptest.Server } @@ -43,6 +46,13 @@ type topicRec struct { Cat int } +type pmRec struct { + TopicID int + Title string + Targets []string + Raw string +} + func newFake(t *testing.T) *fakeDiscourse { t.Helper() f := &fakeDiscourse{ @@ -111,13 +121,47 @@ func newFake(t *testing.T) *fakeDiscourse { writeJSON(w, 405, nil) return } - var req CreatePostRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, 400, map[string]any{"errors": []string{"bad body"}}) + return + } + var req struct { + CreatePostRequest + TargetUsernames string `json:"target_usernames"` + TargetGroupNames string `json:"target_group_names"` + TargetEmails string `json:"target_emails"` + } + if err := json.Unmarshal(body, &req); err != nil { writeJSON(w, 400, map[string]any{"errors": []string{"bad json"}}) return } f.mu.Lock() defer f.mu.Unlock() + f.lastBody = string(body) + if req.TargetUsernames != "" || req.TargetGroupNames != "" || req.TargetEmails != "" { + // private-message creation + targets := splitCSV(req.TargetUsernames) + for _, name := range append(append(targets, splitCSV(req.TargetGroupNames)...), splitCSV(req.TargetEmails)...) { + if strings.HasPrefix(name, "denied") { + writeJSON(w, 403, map[string]any{"errors": []string{"You are not permitted to view the requested resource."}}) + return + } + } + if strings.TrimSpace(req.Title) == "" { + writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}}) + return + } + tid := f.nextT + f.nextT++ + slug := strings.ToLower(strings.ReplaceAll(req.Title, " ", "-")) + f.pms = append(f.pms, pmRec{TopicID: tid, Title: req.Title, Targets: targets, Raw: req.Raw}) + pid := f.nextPost + f.nextPost++ + f.posts[pid] = postRec{Post: Post{ID: pid, TopicID: tid, PostNumber: 1, Raw: req.Raw}} + writeJSON(w, 200, map[string]any{"id": pid, "topic_id": tid, "topic_slug": slug}) + return + } if req.TopicID == 0 { // topic creation if strings.TrimSpace(req.Title) == "" || req.Category == 0 { writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}}) @@ -264,6 +308,21 @@ func writeJSON(w http.ResponseWriter, code int, body any) { _ = json.NewEncoder(w).Encode(body) } +// splitCSV splits a comma-joined target list ("ops,reachableceo"). +func splitCSV(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := parts[:0] + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + func testClient(t *testing.T, f *fakeDiscourse) *Client { t.Helper() c, err := New(f.srv.URL, testKey, "") diff --git a/messages.go b/messages.go new file mode 100644 index 0000000..7b4d1e1 --- /dev/null +++ b/messages.go @@ -0,0 +1,77 @@ +// Private messages: sent through the same POST /posts.json endpoint as +// topics, but with target_usernames / target_group_names / target_emails +// instead of a category — Discourse then creates a private-message topic +// (archetype private_message) visible only to sender and recipients. +package discourse + +import ( + "context" + "fmt" + "strings" +) + +// SendMessageRequest sends a private message. Exactly one of the three +// target lists is the common case; any non-empty one works. At least one +// target overall is required — Discourse 422s without one. +type SendMessageRequest struct { + Title string + Raw string + TargetUsernames []string + TargetGroupNames []string + TargetEmails []string +} + +// sendMessageWire is the POST /posts.json body: Discourse expects the +// target lists comma-joined strings, not JSON arrays. +type sendMessageWire struct { + Title string `json:"title"` + Raw string `json:"raw"` + TargetUsernames string `json:"target_usernames,omitempty"` + TargetGroupNames string `json:"target_group_names,omitempty"` + TargetEmails string `json:"target_emails,omitempty"` +} + +// SendMessageResult is the response: a fresh private-message topic, +// same shape as a topic creation (URL() renders its link). +type SendMessageResult = CreateTopicResult + +// SendMessage posts a private message and returns the new PM topic's +// ids. Unknown recipients fail server-side as 403 (ErrForbidden) — +// that is the "not permitted to view the requested resource" case. +func (c *Client) SendMessage(ctx context.Context, req SendMessageRequest) (*SendMessageResult, error) { + if strings.TrimSpace(req.Title) == "" { + return nil, fmt.Errorf("%w: message title is required", ErrInvalidRequest) + } + if strings.TrimSpace(req.Raw) == "" { + return nil, fmt.Errorf("%w: message raw body is required", ErrInvalidRequest) + } + if len(req.TargetUsernames) == 0 && len(req.TargetGroupNames) == 0 && len(req.TargetEmails) == 0 { + return nil, fmt.Errorf("%w: at least one target (username, group or email) is required", ErrInvalidRequest) + } + wire := sendMessageWire{ + Title: req.Title, + Raw: req.Raw, + TargetUsernames: strings.Join(cleanList(req.TargetUsernames), ","), + TargetGroupNames: strings.Join(cleanList(req.TargetGroupNames), ","), + TargetEmails: strings.Join(cleanList(req.TargetEmails), ","), + } + var out SendMessageResult + if err := c.Post(ctx, "/posts.json", wire, &out); err != nil { + return nil, err + } + if out.TopicID == 0 { + return nil, fmt.Errorf("%w: message sent but response carries no topic_id", ErrMalformedResponse) + } + return &out, nil +} + +// cleanList trims whitespace and drops empties from a target list. +func cleanList(in []string) []string { + out := make([]string, 0, len(in)) + for _, v := range in { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + return out +} diff --git a/messages_test.go b/messages_test.go new file mode 100644 index 0000000..5c24341 --- /dev/null +++ b/messages_test.go @@ -0,0 +1,96 @@ +package discourse + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestSendMessageRequiresTargets(t *testing.T) { + f := newFake(t) + c := testClient(t, f) + _, err := c.SendMessage(context.Background(), SendMessageRequest{Title: "t", Raw: "r"}) + if !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("want ErrInvalidRequest without targets, got %v", err) + } + if f.saw("/posts.json") { + t.Fatal("no request should have been sent without targets") + } +} + +func TestSendMessageRequiresTitleAndRaw(t *testing.T) { + c, _ := New("https://ok.example", testKey, "") + if _, err := c.SendMessage(context.Background(), SendMessageRequest{Raw: "r", TargetUsernames: []string{"ops"}}); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("missing title: want ErrInvalidRequest, got %v", err) + } + if _, err := c.SendMessage(context.Background(), SendMessageRequest{Title: "t", TargetUsernames: []string{"ops"}}); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("missing raw: want ErrInvalidRequest, got %v", err) + } +} + +func TestSendMessage(t *testing.T) { + f := newFake(t) + c := testClient(t, f) + res, err := c.SendMessage(context.Background(), SendMessageRequest{ + Title: "Fleet ping", + Raw: "check the briefing", + TargetUsernames: []string{"ops", "reachableceo"}, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if res.TopicID == 0 || res.TopicSlug == "" { + t.Fatalf("want topic ids in result, got %+v", res) + } + if u := res.URL(f.srv.URL); !strings.Contains(u, "/t/fleet-ping/") { + t.Fatalf("PM URL should be /t//, got %q", u) + } + body := f.lastBody + for _, want := range []string{`"title":"Fleet ping"`, `"target_usernames":"ops,reachableceo"`} { + if !strings.Contains(body, want) { + t.Fatalf("wire body missing %s: %s", want, body) + } + } + if strings.Contains(body, `"target_usernames":["`) { + t.Fatalf("targets must be comma-joined, not a JSON array: %s", body) + } + if got := f.pms; len(got) != 1 || got[0].TopicID != res.TopicID || got[0].Title != "Fleet ping" { + t.Fatalf("fake did not record the PM: %+v", got) + } + if got := f.pms[0].Targets; len(got) != 2 || got[0] != "ops" || got[1] != "reachableceo" { + t.Fatalf("recipients not recorded: %+v", got) + } +} + +func TestSendMessageGroupAndEmailTargets(t *testing.T) { + f := newFake(t) + c := testClient(t, f) + _, err := c.SendMessage(context.Background(), SendMessageRequest{ + Title: "Bulk", + Raw: "hi", + TargetGroupNames: []string{"staff", "admins"}, + TargetEmails: []string{"a@x.test"}, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + for _, want := range []string{`"target_group_names":"staff,admins"`, `"target_emails":"a@x.test"`} { + if !strings.Contains(f.lastBody, want) { + t.Fatalf("wire body missing %s: %s", want, f.lastBody) + } + } +} + +func TestSendMessageForbiddenIsTyped(t *testing.T) { + f := newFake(t) + c := testClient(t, f) + _, err := c.SendMessage(context.Background(), SendMessageRequest{ + Title: "nope", + Raw: "x", + TargetUsernames: []string{"denied"}, + }) + if !errors.Is(err, ErrForbidden) { + t.Fatalf("want ErrForbidden for undeliverable recipient, got %v", err) + } +}