fluid-telegram/internal/compose/publish.go
tegwick 94ee3f4f9c FT-WP-0001-T06: add the temporary quoted-remembrance composer.
Assistant: grok
Assistant-Session: 01a1097f-0521-7fd1-8343-23631f633e2e
2026-10-05 02:50:27 +02:00

142 lines
3.9 KiB
Go

package compose
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// postPayload is the R-1 request body. additionalProperties is false on the
// schema, so this struct is the whole request: queue fields such as approved
// and publication are not sent. visual_url and tags are omitted; this
// strategy does not set them.
type postPayload struct {
PostID string `json:"post_id"`
Body string `json:"body"`
SourceEntryID string `json:"source_entry_id"`
SourceEntryURL string `json:"source_entry_url"`
ReviewedBy string `json:"reviewed_by"`
SubjectDisplayName string `json:"subject_display_name"`
SubjectConsent string `json:"subject_consent"`
Variant string `json:"variant"`
}
// Publish POSTs an approved draft to {adapterBase}/v1/channel-posts.
// It reads the draft from the queue and refuses when that record is not
// approved, when reviewed_by is empty, or when subject_consent is not one of
// the three caller-supplied enum values. HTTP 200 and 201 are recorded on
// the draft. Redirects to another host are not followed.
func (q *Queue) Publish(ctx context.Context, adapterBase, postID string) (Draft, error) {
d, err := q.Get(postID)
if err != nil {
return Draft{}, err
}
if err := publishable(d); err != nil {
return Draft{}, fmt.Errorf("publish %s: %w", postID, err)
}
endpoint, err := channelPostsURL(adapterBase)
if err != nil {
return Draft{}, err
}
payload := postPayload{
PostID: d.PostID,
Body: d.Body,
SourceEntryID: d.SourceEntryID,
SourceEntryURL: d.SourceEntryURL,
ReviewedBy: d.ReviewedBy,
SubjectDisplayName: d.SubjectDisplayName,
SubjectConsent: d.SubjectConsent,
Variant: d.Variant,
}
raw, err := json.Marshal(payload)
if err != nil {
return Draft{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
if err != nil {
return Draft{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := publishClient().Do(req)
if err != nil {
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
return Draft{}, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return Draft{}, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return Draft{}, fmt.Errorf("publish %s: HTTP %d", postID, resp.StatusCode)
}
d.Publication = &Publication{StatusCode: resp.StatusCode, Body: string(respBody)}
if err := q.save(d); err != nil {
return Draft{}, err
}
return d, nil
}
func publishable(d Draft) error {
if !d.Approved {
return ErrNotApproved
}
if strings.TrimSpace(d.ReviewedBy) == "" {
return ErrReviewer
}
if _, ok := consentBases[d.SubjectConsent]; !ok {
return fmt.Errorf("%w: got %q", ErrConsent, d.SubjectConsent)
}
return nil
}
func channelPostsURL(base string) (string, error) {
u, err := url.Parse(base)
if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return "", fmt.Errorf("adapter base URL must be absolute http(s)")
}
u.Path = strings.TrimRight(u.Path, "/") + "/v1/channel-posts"
u.RawQuery = ""
u.Fragment = ""
return u.String(), nil
}
func publishClient() *http.Client {
return &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
if hostPort(req.URL) != hostPort(via[0].URL) {
return fmt.Errorf("%w: %s", ErrCrossHost, req.URL.Host)
}
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
}
func hostPort(u *url.URL) string {
host := strings.ToLower(u.Hostname())
port := u.Port()
if port == "" {
if strings.EqualFold(u.Scheme, "https") {
port = "443"
} else {
port = "80"
}
}
return host + ":" + port
}