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.
This commit is contained in:
+61
-2
@@ -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, "")
|
||||
|
||||
+77
@@ -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
|
||||
}
|
||||
@@ -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/<slug>/<id>, 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user