Covers the MOPAC briefing/report surface (Redmine 495 Part A): category
list/create (create needs the admin-scoped key; current key 403s, typed
as ErrForbidden), topic create/list/latest/get, post create/update/get,
current-user identity probe, and a Do() JSON passthrough so unmodeled
endpoints need no client release. Key is env/constructor-only, never a
flag, never logged; errors classify via errors.Is. Fake-server unit
tests + containerized end-to-end smoke (redaction sweep included); live
reads verified against community.turnsys.com.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
122 lines
3.7 KiB
Go
122 lines
3.7 KiB
Go
// Posts: create (POST /posts.json — replies or topic creation, the same
|
|
// endpoint), update (PUT /posts/<id>.json), and the session identity
|
|
// check (GET /session/current.json) used by smoke tests and health probes.
|
|
package discourse
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Post is the post payload subset callers use.
|
|
type Post struct {
|
|
ID int `json:"id"`
|
|
TopicID int `json:"topic_id"`
|
|
PostNumber int `json:"post_number"`
|
|
Raw string `json:"raw"`
|
|
Cooked string `json:"cooked"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// CreatePostRequest posts into an existing topic (topic_id set) or
|
|
// creates a topic (topic_id zero + title + category — prefer
|
|
// CreateTopic for that path).
|
|
type CreatePostRequest struct {
|
|
TopicID int `json:"topic_id"`
|
|
Raw string `json:"raw"`
|
|
Title string `json:"title,omitempty"`
|
|
Category int `json:"category,omitempty"`
|
|
}
|
|
|
|
// CreatePostResult is the POST /posts.json response.
|
|
type CreatePostResult struct {
|
|
PostID int `json:"id"`
|
|
TopicID int `json:"topic_id"`
|
|
TopicSlug string `json:"topic_slug"`
|
|
}
|
|
|
|
// CreatePost posts markdown into a topic and returns the ids.
|
|
func (c *Client) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResult, error) {
|
|
if req.TopicID <= 0 {
|
|
return nil, fmt.Errorf("%w: topic_id is required (use CreateTopic for new topics)", ErrInvalidRequest)
|
|
}
|
|
if strings.TrimSpace(req.Raw) == "" {
|
|
return nil, fmt.Errorf("%w: post raw body is required", ErrInvalidRequest)
|
|
}
|
|
var out CreatePostResult
|
|
if err := c.Post(ctx, "/posts.json", req, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// UpdatePost replaces a post's markdown body (PUT /posts/<id>.json) with
|
|
// an optional edit reason.
|
|
func (c *Client) UpdatePost(ctx context.Context, postID int, raw, editReason string) (*Post, error) {
|
|
if postID <= 0 {
|
|
return nil, fmt.Errorf("%w: post id must be > 0", ErrInvalidRequest)
|
|
}
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil, fmt.Errorf("%w: post raw body is required", ErrInvalidRequest)
|
|
}
|
|
body := map[string]any{
|
|
"post": map[string]any{
|
|
"raw": raw,
|
|
},
|
|
}
|
|
if editReason != "" {
|
|
body["post"].(map[string]any)["edit_reason"] = editReason
|
|
}
|
|
var post struct {
|
|
Post Post `json:"post"`
|
|
}
|
|
if err := c.Put(ctx, "/posts/"+fmt.Sprint(postID)+".json", body, &post); err != nil {
|
|
return nil, err
|
|
}
|
|
if post.Post.ID == 0 {
|
|
return nil, fmt.Errorf("%w: post updated but response carries no id", ErrMalformedResponse)
|
|
}
|
|
return &post.Post, nil
|
|
}
|
|
|
|
// GetPost fetches one post by id (GET /posts/<id>.json).
|
|
func (c *Client) GetPost(ctx context.Context, postID int) (*Post, error) {
|
|
if postID <= 0 {
|
|
return nil, fmt.Errorf("%w: post id must be > 0", ErrInvalidRequest)
|
|
}
|
|
var payload struct {
|
|
Post Post `json:"post"`
|
|
}
|
|
if err := c.Get(ctx, "/posts/"+fmt.Sprint(postID)+".json", &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
if payload.Post.ID == 0 {
|
|
return nil, fmt.Errorf("%w: post response carries no id", ErrMalformedResponse)
|
|
}
|
|
return &payload.Post, nil
|
|
}
|
|
|
|
// CurrentUser is the identity the key acts as — the cheapest live
|
|
// verification of url + key + username (any 2xx means the trio works).
|
|
type CurrentUser struct {
|
|
ID int `json:"id"`
|
|
Username string `json:"username"`
|
|
Admin bool `json:"admin"`
|
|
}
|
|
|
|
// CurrentUser runs GET /session/current.json.
|
|
func (c *Client) CurrentUser(ctx context.Context) (*CurrentUser, error) {
|
|
var payload struct {
|
|
CurrentUser CurrentUser `json:"current_user"`
|
|
}
|
|
if err := c.Get(ctx, "/session/current.json", &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
if payload.CurrentUser.Username == "" {
|
|
return nil, fmt.Errorf("%w: no current_user in response", ErrMalformedResponse)
|
|
}
|
|
return &payload.CurrentUser, nil
|
|
}
|