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"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,6 +29,8 @@ type fakeDiscourse struct {
|
|||||||
topics map[int]topicRec
|
topics map[int]topicRec
|
||||||
nextT int
|
nextT int
|
||||||
searchFail int
|
searchFail int
|
||||||
|
pms []pmRec // private messages, in creation order
|
||||||
|
lastBody string // raw JSON body of the last POST /posts.json
|
||||||
srv *httptest.Server
|
srv *httptest.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +46,13 @@ type topicRec struct {
|
|||||||
Cat int
|
Cat int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type pmRec struct {
|
||||||
|
TopicID int
|
||||||
|
Title string
|
||||||
|
Targets []string
|
||||||
|
Raw string
|
||||||
|
}
|
||||||
|
|
||||||
func newFake(t *testing.T) *fakeDiscourse {
|
func newFake(t *testing.T) *fakeDiscourse {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
f := &fakeDiscourse{
|
f := &fakeDiscourse{
|
||||||
@@ -111,13 +121,47 @@ func newFake(t *testing.T) *fakeDiscourse {
|
|||||||
writeJSON(w, 405, nil)
|
writeJSON(w, 405, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req CreatePostRequest
|
body, err := io.ReadAll(r.Body)
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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"}})
|
writeJSON(w, 400, map[string]any{"errors": []string{"bad json"}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
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 req.TopicID == 0 { // topic creation
|
||||||
if strings.TrimSpace(req.Title) == "" || req.Category == 0 {
|
if strings.TrimSpace(req.Title) == "" || req.Category == 0 {
|
||||||
writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}})
|
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)
|
_ = 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 {
|
func testClient(t *testing.T, f *fakeDiscourse) *Client {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := New(f.srv.URL, testKey, "")
|
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