FT-WP-0001-T06: add the temporary quoted-remembrance composer.
Assistant: grok Assistant-Session: 01a1097f-0521-7fd1-8343-23631f633e2e
This commit is contained in:
parent
43fd92f375
commit
94ee3f4f9c
7 changed files with 1438 additions and 4 deletions
135
cmd/compose/main.go
Normal file
135
cmd/compose/main.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// Command compose is the temporary quoted-remembrance draft tool.
|
||||
//
|
||||
// It writes a review queue the caller names and, once a person has approved
|
||||
// a draft with an explicit consent basis, POSTs that draft to an adapter's
|
||||
// /v1/channel-posts. It has no path into the adapter package or Telegram.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/tegwick/fluid-telegram/internal/compose"
|
||||
)
|
||||
|
||||
const usage = `compose — temporary quoted-remembrance drafts
|
||||
|
||||
compose draft --entry <file> --queue <dir> (--source-url <url> | --base-url <url>)
|
||||
compose approve --queue <dir> --post <id> --reviewer <name> --consent explicit|standing|not-required
|
||||
compose publish --queue <dir> --post <id> --adapter <base>
|
||||
|
||||
Consent is never defaulted. A draft is not published until approve has
|
||||
recorded a reviewer and a consent basis the caller supplied.
|
||||
`
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "compose:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errors.New(usage)
|
||||
}
|
||||
switch args[0] {
|
||||
case "draft":
|
||||
return cmdDraft(args[1:])
|
||||
case "approve":
|
||||
return cmdApprove(args[1:])
|
||||
case "publish":
|
||||
return cmdPublish(args[1:])
|
||||
case "-h", "--help", "help":
|
||||
fmt.Fprint(os.Stderr, usage)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q\n%s", args[0], usage)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdDraft(args []string) error {
|
||||
fs := flag.NewFlagSet("draft", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
entry := fs.String("entry", "", "hall entry markdown")
|
||||
queue := fs.String("queue", "", "review queue directory")
|
||||
sourceURL := fs.String("source-url", "", "explicit source entry URL")
|
||||
baseURL := fs.String("base-url", "", "base URL; /hall-of-helix/<id> is appended")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *entry == "" || *queue == "" {
|
||||
return errors.New("draft requires --entry and --queue")
|
||||
}
|
||||
raw, err := os.ReadFile(*entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := compose.Compose(string(raw), compose.Source{URL: *sourceURL, Base: *baseURL})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q, err := compose.Open(*queue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := q.Put(d); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(d.PostID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdApprove(args []string) error {
|
||||
fs := flag.NewFlagSet("approve", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
queue := fs.String("queue", "", "review queue directory")
|
||||
post := fs.String("post", "", "draft post_id")
|
||||
reviewer := fs.String("reviewer", "", "person approving the draft")
|
||||
consent := fs.String("consent", "", "explicit, standing, or not-required")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *queue == "" || *post == "" || *reviewer == "" {
|
||||
return errors.New("approve requires --queue, --post, and --reviewer")
|
||||
}
|
||||
if *consent == "" {
|
||||
return errors.New("--consent is required (explicit, standing, or not-required); it is not defaulted")
|
||||
}
|
||||
q, err := compose.Open(*queue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := q.Approve(*post, *reviewer, *consent); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(*post)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdPublish(args []string) error {
|
||||
fs := flag.NewFlagSet("publish", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
queue := fs.String("queue", "", "review queue directory")
|
||||
post := fs.String("post", "", "draft post_id")
|
||||
adapter := fs.String("adapter", "", "adapter base URL")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *queue == "" || *post == "" || *adapter == "" {
|
||||
return errors.New("publish requires --queue, --post, and --adapter")
|
||||
}
|
||||
q, err := compose.Open(*queue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := q.Publish(context.Background(), *adapter, *post)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("published %s %d\n", d.PostID, d.Publication.StatusCode)
|
||||
return nil
|
||||
}
|
||||
117
cmd/compose/main_test.go
Normal file
117
cmd/compose/main_test.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const cliEntry = `---
|
||||
id: hall-worker-cli
|
||||
type: worker-entry
|
||||
display_name: CLI
|
||||
status: draft
|
||||
---
|
||||
|
||||
# A title
|
||||
|
||||
## What I would want remembered
|
||||
|
||||
Remember this line.
|
||||
`
|
||||
|
||||
func TestCLIDraftApprovePublish(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
entry := filepath.Join(dir, "entry.md")
|
||||
if err := os.WriteFile(entry, []byte(cliEntry), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue := filepath.Join(dir, "queue")
|
||||
if err := run([]string{"draft", "--entry", entry, "--queue", queue, "--base-url", "https://example.test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := onlyPostID(t, queue)
|
||||
raw, err := os.ReadFile(filepath.Join(queue, id+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "reviewed_by") || strings.Contains(string(raw), "subject_consent") {
|
||||
t.Fatalf("draft file has review fields: %s", raw)
|
||||
}
|
||||
err = run([]string{"approve", "--queue", queue, "--post", id, "--reviewer", "Ada"})
|
||||
if err == nil {
|
||||
t.Fatal("approve without --consent succeeded")
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("decode: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"state":"published"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := os.Stat(filepath.Join(queue, id+".json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run([]string{"publish", "--queue", queue, "--post", id, "--adapter", srv.URL}); err == nil {
|
||||
t.Fatal("publish before approval succeeded")
|
||||
}
|
||||
if body != nil {
|
||||
t.Fatalf("publish before approval sent a body: %#v", body)
|
||||
}
|
||||
if err := run([]string{"approve", "--queue", queue, "--post", id, "--reviewer", "Ada", "--consent", "explicit"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run([]string{"publish", "--queue", queue, "--post", id, "--adapter", srv.URL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["post_id"] != id || body["reviewed_by"] != "Ada" || body["subject_consent"] != "explicit" || body["variant"] != "quoted-remembrance-v1" {
|
||||
t.Fatalf("POST body = %#v", body)
|
||||
}
|
||||
if body["source_entry_url"] != "https://example.test/hall-of-helix/hall-worker-cli" {
|
||||
t.Fatalf("source url = %#v", body["source_entry_url"])
|
||||
}
|
||||
if !strings.Contains(body["body"].(string), "Remember this line.") {
|
||||
t.Fatalf("body = %#v", body["body"])
|
||||
}
|
||||
}
|
||||
|
||||
func onlyPostID(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var id string
|
||||
for _, e := range ents {
|
||||
if !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var d struct {
|
||||
PostID string `json:"post_id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "" {
|
||||
t.Fatal("queue has more than one draft")
|
||||
}
|
||||
id = d.PostID
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("queue is empty")
|
||||
}
|
||||
return id
|
||||
}
|
||||
293
internal/compose/compose.go
Normal file
293
internal/compose/compose.go
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
// Package compose is the temporary step that turns one hall-of-helix entry
|
||||
// into a channel-post draft.
|
||||
//
|
||||
// INTENT.md says this repository renders and delivers and does not itself
|
||||
// write. This package is the explicit temporary exception (FT-WP-0001-T06),
|
||||
// kept separate so the adapter stays deterministic. Its only call toward
|
||||
// publishing is HTTP POST /v1/channel-posts. It does not import the adapter,
|
||||
// the evidence store, or the Telegram client, and it does not call a model.
|
||||
//
|
||||
// The strategy label is quoted-remembrance-v1: the draft quotes the entry
|
||||
// section "What I would want remembered" verbatim, plus the display name, the
|
||||
// title, and a link back to the source. It does not paraphrase.
|
||||
package compose
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
// Variant is the composition strategy label recorded on every draft.
|
||||
Variant = "quoted-remembrance-v1"
|
||||
|
||||
// maxBodyChars is Telegram's single-message limit and the R-1 body
|
||||
// maxLength. A draft that does not fit is refused, never truncated.
|
||||
maxBodyChars = 4096
|
||||
|
||||
// maxPostID is contracts/r1.openapi.yaml's post_id maxLength.
|
||||
maxPostID = 128
|
||||
|
||||
rememberedHeading = "What I would want remembered"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRemembered = errors.New(`refusing draft: section "What I would want remembered" is missing or empty`)
|
||||
ErrTooLong = errors.New("refusing draft: composed body exceeds 4096 characters")
|
||||
ErrSource = errors.New("refusing draft: an explicit source URL or base URL is required")
|
||||
ErrNotApproved = errors.New("refusing to publish: draft is not approved")
|
||||
ErrReviewer = errors.New("refusing to publish: reviewed_by is required")
|
||||
ErrConsent = errors.New("subject_consent must be one of explicit, standing, not-required")
|
||||
ErrCrossHost = errors.New("refusing redirect to another host")
|
||||
)
|
||||
|
||||
// consentBases is the subject_consent enum in contracts/r1.openapi.yaml.
|
||||
// Absence of objection is not a member, and none of these is a default.
|
||||
var consentBases = map[string]struct{}{
|
||||
"explicit": {},
|
||||
"standing": {},
|
||||
"not-required": {},
|
||||
}
|
||||
|
||||
// entryIDRe is the hall slug: URL-safe, and the public path segment.
|
||||
var entryIDRe = regexp.MustCompile(`^[A-Za-z0-9-]+$`)
|
||||
|
||||
// Source locates the hall entry. Exactly one of URL and Base must be set.
|
||||
// URL is used as given. Base is an explicit origin or prefix; the canonical
|
||||
// path /hall-of-helix/<id> is appended. A hostname is never guessed.
|
||||
type Source struct {
|
||||
URL string
|
||||
Base string
|
||||
}
|
||||
|
||||
// Draft is one composed post that has not been published. ReviewedBy and
|
||||
// SubjectConsent stay empty until Approve. The queue file may carry review
|
||||
// state; the publish request does not send that state as extra fields.
|
||||
type Draft struct {
|
||||
PostID string `json:"post_id"`
|
||||
Body string `json:"body"`
|
||||
SourceEntryID string `json:"source_entry_id"`
|
||||
SourceEntryURL string `json:"source_entry_url"`
|
||||
SubjectDisplayName string `json:"subject_display_name"`
|
||||
Variant string `json:"variant"`
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
SubjectConsent string `json:"subject_consent,omitempty"`
|
||||
Approved bool `json:"approved"`
|
||||
Publication *Publication `json:"publication,omitempty"`
|
||||
}
|
||||
|
||||
// Publication is the adapter response recorded after HTTP 200 or 201.
|
||||
type Publication struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
type frontmatter struct {
|
||||
ID string `yaml:"id"`
|
||||
Type string `yaml:"type"`
|
||||
DisplayName string `yaml:"display_name"`
|
||||
Status string `yaml:"status"`
|
||||
}
|
||||
|
||||
// Compose reads one hall entry and returns an unapproved draft.
|
||||
// The same entry text, variant, and composed body always yield the same post_id.
|
||||
// A different body is a recomposition and therefore a new post_id.
|
||||
func Compose(markdown string, src Source) (Draft, error) {
|
||||
fm, body, err := splitFrontmatter(markdown)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
meta, err := parseFrontmatter(fm)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
title, ok := firstH1(body)
|
||||
if !ok {
|
||||
return Draft{}, errors.New("refusing draft: entry has no title")
|
||||
}
|
||||
remembered, ok := section(body, rememberedHeading)
|
||||
if !ok {
|
||||
return Draft{}, ErrRemembered
|
||||
}
|
||||
sourceURL, err := resolveSource(meta.ID, src)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
draftBody := composeBody(meta.DisplayName, title, remembered, sourceURL)
|
||||
if n := utf8.RuneCountInString(draftBody); n > maxBodyChars {
|
||||
return Draft{}, fmt.Errorf("%w (%d characters)", ErrTooLong, n)
|
||||
}
|
||||
return Draft{
|
||||
PostID: derivePostID(meta.ID, Variant, draftBody),
|
||||
Body: draftBody,
|
||||
SourceEntryID: meta.ID,
|
||||
SourceEntryURL: sourceURL,
|
||||
SubjectDisplayName: meta.DisplayName,
|
||||
Variant: Variant,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func splitFrontmatter(raw string) (string, string, error) {
|
||||
raw = strings.TrimPrefix(raw, "\uFEFF")
|
||||
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||||
raw = strings.ReplaceAll(raw, "\r", "\n")
|
||||
lines := strings.Split(raw, "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return "", "", errors.New("entry must open with YAML frontmatter")
|
||||
}
|
||||
end := -1
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if strings.TrimSpace(lines[i]) == "---" {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
return "", "", errors.New("entry frontmatter is not closed")
|
||||
}
|
||||
return strings.Join(lines[1:end], "\n"), strings.Join(lines[end+1:], "\n"), nil
|
||||
}
|
||||
|
||||
func parseFrontmatter(raw string) (frontmatter, error) {
|
||||
var fm frontmatter
|
||||
if err := yaml.Unmarshal([]byte(raw), &fm); err != nil {
|
||||
return frontmatter{}, fmt.Errorf("parse frontmatter: %w", err)
|
||||
}
|
||||
fm.ID = strings.TrimSpace(fm.ID)
|
||||
fm.Type = strings.TrimSpace(fm.Type)
|
||||
fm.DisplayName = strings.TrimSpace(fm.DisplayName)
|
||||
fm.Status = strings.TrimSpace(fm.Status)
|
||||
if fm.ID == "" || fm.DisplayName == "" || fm.Status == "" {
|
||||
return frontmatter{}, errors.New("refusing draft: id, display_name, and status are required")
|
||||
}
|
||||
if !entryIDRe.MatchString(fm.ID) {
|
||||
return frontmatter{}, fmt.Errorf("refusing draft: id %q is not a hall slug", fm.ID)
|
||||
}
|
||||
if fm.Type != "worker-entry" {
|
||||
return frontmatter{}, fmt.Errorf("refusing draft: type %q is not worker-entry", fm.Type)
|
||||
}
|
||||
if utf8.RuneCountInString(fm.DisplayName) > 128 {
|
||||
return frontmatter{}, errors.New("refusing draft: display_name exceeds 128 characters")
|
||||
}
|
||||
return fm, nil
|
||||
}
|
||||
|
||||
// atxHeading reports the text of a markdown ATX heading. The level is the
|
||||
// number of leading hashes. Closing hashes are not part of the text.
|
||||
func atxHeading(line string) (level int, text string, ok bool) {
|
||||
s := strings.TrimRight(line, " \t")
|
||||
i := 0
|
||||
for i < len(s) && s[i] == '#' {
|
||||
i++
|
||||
}
|
||||
if i < 1 || i > 6 || i == len(s) || (s[i] != ' ' && s[i] != '\t') {
|
||||
return 0, "", false
|
||||
}
|
||||
text = strings.TrimSpace(s[i:])
|
||||
text = strings.TrimSpace(strings.TrimRight(text, "#"))
|
||||
if text == "" {
|
||||
return 0, "", false
|
||||
}
|
||||
return i, text, true
|
||||
}
|
||||
|
||||
func firstH1(markdown string) (string, bool) {
|
||||
for _, line := range strings.Split(markdown, "\n") {
|
||||
level, text, ok := atxHeading(line)
|
||||
if ok && level == 1 {
|
||||
return text, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// section returns the body under a heading whose text is exactly name.
|
||||
// The match is the heading text, not the hashes. The next ATX heading ends
|
||||
// it. Surrounding blank lines are the section boundary, not the quotation.
|
||||
func section(markdown, name string) (string, bool) {
|
||||
lines := strings.Split(markdown, "\n")
|
||||
start := -1
|
||||
for i, line := range lines {
|
||||
_, text, ok := atxHeading(line)
|
||||
if ok && text == name {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return "", false
|
||||
}
|
||||
end := len(lines)
|
||||
for i := start; i < len(lines); i++ {
|
||||
if _, _, ok := atxHeading(lines[i]); ok {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
chunk := strings.Trim(strings.Join(lines[start:end], "\n"), "\n")
|
||||
if strings.TrimSpace(chunk) == "" {
|
||||
return "", false
|
||||
}
|
||||
return chunk, true
|
||||
}
|
||||
|
||||
func resolveSource(id string, src Source) (string, error) {
|
||||
switch {
|
||||
case src.URL != "" && src.Base != "":
|
||||
return "", errors.New("pass either a source URL or a base URL, not both")
|
||||
case src.URL != "":
|
||||
u, err := url.ParseRequestURI(src.URL)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return "", fmt.Errorf("source URL %q is not an absolute http(s) URL", src.URL)
|
||||
}
|
||||
return src.URL, nil
|
||||
case src.Base != "":
|
||||
u, err := url.Parse(src.Base)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return "", fmt.Errorf("%w: base URL must be absolute", ErrSource)
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/") + "/hall-of-helix/" + id
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return u.String(), nil
|
||||
default:
|
||||
return "", ErrSource
|
||||
}
|
||||
}
|
||||
|
||||
func composeBody(displayName, title, remembered, sourceURL string) string {
|
||||
return displayName + "\n" + title + "\n\n" + quote(remembered) + "\n\n" + sourceURL
|
||||
}
|
||||
|
||||
// quote marks the section as a quotation without changing its words.
|
||||
func quote(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
if line == "" {
|
||||
lines[i] = ">"
|
||||
} else {
|
||||
lines[i] = "> " + line
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// derivePostID mixes the entry id, the variant, and a hash of the composed
|
||||
// body. The same three inputs keep the same id; a new body is a new id.
|
||||
// The visible form is shortened only when it would exceed the contract limit.
|
||||
func derivePostID(entryID, variant, body string) string {
|
||||
sum := sha256.Sum256([]byte(entryID + "\x00" + variant + "\x00" + body))
|
||||
id := entryID + "." + variant + "." + hex.EncodeToString(sum[:8])
|
||||
if len(id) <= maxPostID {
|
||||
return id
|
||||
}
|
||||
return variant + "." + hex.EncodeToString(sum[:])
|
||||
}
|
||||
591
internal/compose/compose_test.go
Normal file
591
internal/compose/compose_test.go
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const fixtureEntry = `---
|
||||
id: hall-worker-ada-lens
|
||||
type: worker-entry
|
||||
worker_kind: human
|
||||
display_name: "Ada Lovelace"
|
||||
created_at: "2026-10-05T00:00:00Z"
|
||||
recorded_at: "2026-10-05"
|
||||
status: complete
|
||||
---
|
||||
|
||||
# The lens stayed honest
|
||||
|
||||
## Who I was
|
||||
|
||||
A paragraph that must not be quoted.
|
||||
|
||||
## What I would want remembered
|
||||
|
||||
Count the clock before comparing the numbers.
|
||||
Don't trim the clause & don't paraphrase.
|
||||
|
||||
A later duplicate can cross a boundary.
|
||||
|
||||
## Durable legacy
|
||||
|
||||
- some path that must not be quoted
|
||||
`
|
||||
|
||||
func TestComposeQuotesRememberedSection(t *testing.T) {
|
||||
d, err := Compose(fixtureEntry, Source{Base: "https://example.test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Variant != Variant {
|
||||
t.Fatalf("variant = %q", d.Variant)
|
||||
}
|
||||
if d.SourceEntryID != "hall-worker-ada-lens" {
|
||||
t.Fatalf("source_entry_id = %q", d.SourceEntryID)
|
||||
}
|
||||
if d.SubjectDisplayName != "Ada Lovelace" {
|
||||
t.Fatalf("display name = %q", d.SubjectDisplayName)
|
||||
}
|
||||
wantURL := "https://example.test/hall-of-helix/hall-worker-ada-lens"
|
||||
if d.SourceEntryURL != wantURL {
|
||||
t.Fatalf("source url = %q", d.SourceEntryURL)
|
||||
}
|
||||
if d.ReviewedBy != "" || d.SubjectConsent != "" || d.Approved || d.Publication != nil {
|
||||
t.Fatalf("fresh draft carries review state: %+v", d)
|
||||
}
|
||||
if !strings.Contains(d.PostID, "hall-worker-ada-lens") || !strings.Contains(d.PostID, Variant) {
|
||||
t.Fatalf("post_id = %q, want entry id and variant", d.PostID)
|
||||
}
|
||||
if len(d.PostID) > 128 {
|
||||
t.Fatalf("post_id length %d", len(d.PostID))
|
||||
}
|
||||
|
||||
_, body, err := splitFrontmatter(fixtureEntry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remembered, ok := section(body, rememberedHeading)
|
||||
if !ok {
|
||||
t.Fatal("fixture section missing")
|
||||
}
|
||||
if got := unquote(d.Body); got != remembered {
|
||||
t.Fatalf("quotation is not verbatim:\n got: %q\nwant: %q", got, remembered)
|
||||
}
|
||||
lines := strings.Split(d.Body, "\n")
|
||||
if lines[0] != "Ada Lovelace" || lines[1] != "The lens stayed honest" {
|
||||
t.Fatalf("body opening = %q", lines[:2])
|
||||
}
|
||||
if lines[len(lines)-1] != wantURL {
|
||||
t.Fatalf("body does not end with the source link: %q", lines[len(lines)-1])
|
||||
}
|
||||
if strings.Contains(d.Body, "must not be quoted") {
|
||||
t.Fatalf("body includes text outside the remembered section:\n%s", d.Body)
|
||||
}
|
||||
|
||||
again, err := Compose(fixtureEntry, Source{Base: "https://example.test/"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.PostID != d.PostID || again.SourceEntryURL != d.SourceEntryURL {
|
||||
t.Fatalf("trailing slash on the base changed the draft: %+v vs %+v", again.SourceEntryURL, d.SourceEntryURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposePostIDFollowsTheText(t *testing.T) {
|
||||
src := Source{URL: "https://h.test/entries/ada"}
|
||||
first, err := Compose(fixtureEntry, src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := Compose(fixtureEntry, src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.PostID != second.PostID {
|
||||
t.Fatalf("same text produced %q and %q", first.PostID, second.PostID)
|
||||
}
|
||||
if first.SourceEntryURL != "https://h.test/entries/ada" {
|
||||
t.Fatalf("explicit source URL was rewritten: %s", first.SourceEntryURL)
|
||||
}
|
||||
changed := strings.Replace(fixtureEntry, "Count the clock", "Count the clocks", 1)
|
||||
third, err := Compose(changed, src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if third.PostID == first.PostID {
|
||||
t.Fatal("recomposition kept the same post_id")
|
||||
}
|
||||
if !strings.Contains(unquote(third.Body), "Count the clocks") {
|
||||
t.Fatalf("changed words were not quoted: %s", third.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeRefusesWithoutRememberedSection(t *testing.T) {
|
||||
raw := `---
|
||||
id: hall-worker-ada-lens
|
||||
type: worker-entry
|
||||
display_name: Ada Lovelace
|
||||
status: complete
|
||||
---
|
||||
|
||||
# The lens stayed honest
|
||||
|
||||
## Who I was
|
||||
|
||||
A paragraph that must not be quoted.
|
||||
`
|
||||
d, err := Compose(raw, Source{URL: "https://h.test/e"})
|
||||
if !errors.Is(err, ErrRemembered) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if d != (Draft{}) {
|
||||
t.Fatalf("refused compose returned a draft: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeRefusesEmptyRememberedSection(t *testing.T) {
|
||||
for _, remembered := range []string{"", " ", "\n\n"} {
|
||||
d, err := Compose(entryWith(remembered), Source{URL: "https://h.test/e"})
|
||||
if !errors.Is(err, ErrRemembered) {
|
||||
t.Fatalf("remembered %q: err = %v", remembered, err)
|
||||
}
|
||||
if d.Body != "" {
|
||||
t.Fatal("empty section still produced a body")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeRefusesIncompleteEntry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "no frontmatter", raw: "# Title\n\n## What I would want remembered\n\nx\n", want: "frontmatter"},
|
||||
{name: "wrong type", raw: mustReplace(t, "type: worker-entry", "type: note"), want: "worker-entry"},
|
||||
{name: "no title", raw: mustReplace(t, "# The lens stayed honest\n", ""), want: "title"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := Compose(tc.raw, Source{URL: "https://h.test/e"})
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeRequiresExplicitSource(t *testing.T) {
|
||||
_, err := Compose(fixtureEntry, Source{})
|
||||
if !errors.Is(err, ErrSource) {
|
||||
t.Fatalf("missing source: %v", err)
|
||||
}
|
||||
_, err = Compose(fixtureEntry, Source{URL: "https://a.test/e", Base: "https://b.test"})
|
||||
if err == nil {
|
||||
t.Fatal("both source URL and base URL were accepted")
|
||||
}
|
||||
_, err = Compose(fixtureEntry, Source{Base: "/hall-of-helix"})
|
||||
if !errors.Is(err, ErrSource) {
|
||||
t.Fatalf("relative base: %v", err)
|
||||
}
|
||||
d, err := Compose(fixtureEntry, Source{Base: "https://example.test/docs/"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.SourceEntryURL != "https://example.test/docs/hall-of-helix/hall-worker-ada-lens" {
|
||||
t.Fatalf("url = %s", d.SourceEntryURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeRefusesBodyOver4096(t *testing.T) {
|
||||
src := Source{URL: "https://h.test/e"}
|
||||
l1 := composedLen(t, src, "a")
|
||||
l2 := composedLen(t, src, "aa")
|
||||
if l2-l1 != 1 {
|
||||
t.Fatalf("body length is not linear in the quotation: %d then %d", l1, l2)
|
||||
}
|
||||
n := 1 + (4096 - l1)
|
||||
if n < 1 {
|
||||
t.Fatal("fixture overhead already exceeds 4096")
|
||||
}
|
||||
if got := composedLen(t, src, strings.Repeat("a", n)); got != 4096 {
|
||||
t.Fatalf("length at the limit = %d", got)
|
||||
}
|
||||
d, err := Compose(entryWith(strings.Repeat("a", n+1)), src)
|
||||
if !errors.Is(err, ErrTooLong) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if d != (Draft{}) {
|
||||
t.Fatalf("over-long compose returned a draft (%d characters)", utf8.RuneCountInString(d.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutDoesNotApprove(t *testing.T) {
|
||||
q := openQueue(t)
|
||||
d, err := Compose(fixtureEntry, Source{Base: "https://example.test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.ReviewedBy = "sneaky"
|
||||
d.SubjectConsent = "not-required"
|
||||
d.Approved = true
|
||||
if err := q.Put(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := q.Get(d.PostID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Approved || got.ReviewedBy != "" || got.SubjectConsent != "" {
|
||||
t.Fatalf("put stored review state: %+v", got)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(q.dir, d.PostID+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := m["reviewed_by"]; ok {
|
||||
t.Fatal("draft file has reviewed_by")
|
||||
}
|
||||
if _, ok := m["subject_consent"]; ok {
|
||||
t.Fatal("draft file has subject_consent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveRequiresCallerConsent(t *testing.T) {
|
||||
q := openQueue(t)
|
||||
d := mustPut(t, q)
|
||||
for _, consent := range []string{"", "implicit", "not required", "NOT-REQUIRED", "absence-of-objection"} {
|
||||
_, err := q.Approve(d.PostID, "Ada", consent)
|
||||
if !errors.Is(err, ErrConsent) && !errors.Is(err, ErrReviewer) {
|
||||
t.Fatalf("consent %q: err = %v", consent, err)
|
||||
}
|
||||
got, err := q.Get(d.PostID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Approved || got.SubjectConsent != "" || got.ReviewedBy != "" {
|
||||
t.Fatalf("consent %q changed the draft: %+v", consent, got)
|
||||
}
|
||||
}
|
||||
if _, err := q.Approve(d.PostID, " ", "explicit"); !errors.Is(err, ErrReviewer) {
|
||||
t.Fatalf("blank reviewer: %v", err)
|
||||
}
|
||||
for _, consent := range []string{"explicit", "standing", "not-required"} {
|
||||
got, err := q.Approve(d.PostID, "Ada", consent)
|
||||
if err != nil {
|
||||
t.Fatalf("consent %q: %v", consent, err)
|
||||
}
|
||||
if !got.Approved || got.ReviewedBy != "Ada" || got.SubjectConsent != consent {
|
||||
t.Fatalf("approved draft = %+v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRefusedBeforeApproval(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
q := openQueue(t)
|
||||
d := mustPut(t, q)
|
||||
_, err := q.Publish(context.Background(), srv.URL, d.PostID)
|
||||
if !errors.Is(err, ErrNotApproved) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Fatalf("server was hit %d times before approval", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveThenPublishPostsContractFields(t *testing.T) {
|
||||
var got map[string]any
|
||||
var rawBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/channel-posts" {
|
||||
t.Errorf("request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var err error
|
||||
rawBody, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read body: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(rawBody, &got); err != nil {
|
||||
t.Errorf("json: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"state":"published"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
q := openQueue(t)
|
||||
d := mustPut(t, q)
|
||||
if _, err := q.Approve(d.PostID, "Bernd", "explicit"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
published, err := q.Publish(context.Background(), srv.URL, d.PostID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if published.Publication == nil || published.Publication.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("publication = %+v", published.Publication)
|
||||
}
|
||||
stored, err := q.Get(d.PostID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Publication == nil || stored.Publication.Body != `{"state":"published"}` {
|
||||
t.Fatalf("queue publication = %+v", stored.Publication)
|
||||
}
|
||||
|
||||
wantKeys := []string{
|
||||
"post_id", "body", "source_entry_id", "source_entry_url",
|
||||
"reviewed_by", "subject_consent", "variant", "subject_display_name",
|
||||
}
|
||||
if len(got) != len(wantKeys) {
|
||||
t.Fatalf("POST keys = %v", keys(got))
|
||||
}
|
||||
for _, k := range wantKeys {
|
||||
if _, ok := got[k]; !ok {
|
||||
t.Errorf("POST body missing %s", k)
|
||||
}
|
||||
}
|
||||
if got["post_id"] != d.PostID ||
|
||||
got["body"] != d.Body ||
|
||||
got["source_entry_id"] != d.SourceEntryID ||
|
||||
got["source_entry_url"] != d.SourceEntryURL ||
|
||||
got["reviewed_by"] != "Bernd" ||
|
||||
got["subject_consent"] != "explicit" ||
|
||||
got["variant"] != Variant ||
|
||||
got["subject_display_name"] != d.SubjectDisplayName {
|
||||
t.Fatalf("POST body = %#v", got)
|
||||
}
|
||||
if bytesContainsField(rawBody, "approved") || bytesContainsField(rawBody, "publication") {
|
||||
t.Fatalf("POST body has queue fields: %s", rawBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRecords200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"state":"published"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
q := openQueue(t)
|
||||
d := mustPut(t, q)
|
||||
if _, err := q.Approve(d.PostID, "Bernd", "standing"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := q.Publish(context.Background(), srv.URL, d.PostID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Publication == nil || got.Publication.StatusCode != http.StatusOK {
|
||||
t.Fatalf("publication = %+v", got.Publication)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRefusesTamperedQueue(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer srv.Close()
|
||||
q := openQueue(t)
|
||||
d := mustPut(t, q)
|
||||
|
||||
d.Approved = true
|
||||
d.ReviewedBy = "Ada"
|
||||
d.SubjectConsent = "assumed"
|
||||
if err := q.save(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := q.Publish(context.Background(), srv.URL, d.PostID)
|
||||
if !errors.Is(err, ErrConsent) {
|
||||
t.Fatalf("bad consent: %v", err)
|
||||
}
|
||||
|
||||
d.SubjectConsent = "explicit"
|
||||
d.ReviewedBy = ""
|
||||
if err := q.save(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = q.Publish(context.Background(), srv.URL, d.PostID)
|
||||
if !errors.Is(err, ErrReviewer) {
|
||||
t.Fatalf("missing reviewer: %v", err)
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Fatalf("tampered drafts were posted %d times", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRefusesCrossHostRedirect(t *testing.T) {
|
||||
var otherHits int
|
||||
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
otherHits++
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer other.Close()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, other.URL+"/v1/channel-posts", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
q := openQueue(t)
|
||||
d := mustPut(t, q)
|
||||
if _, err := q.Approve(d.PostID, "Bernd", "explicit"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := q.Publish(context.Background(), srv.URL, d.PostID)
|
||||
if !errors.Is(err, ErrCrossHost) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if otherHits != 0 {
|
||||
t.Fatalf("followed redirect to another host (%d hits)", otherHits)
|
||||
}
|
||||
stored, err := q.Get(d.PostID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Publication != nil {
|
||||
t.Fatal("cross-host redirect was recorded as publication")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphExcludesAdapter(t *testing.T) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("caller")
|
||||
}
|
||||
root := filepath.Join(filepath.Dir(file), "..", "..")
|
||||
forbidden := []string{
|
||||
"github.com/tegwick/fluid-telegram/internal/adapter",
|
||||
"github.com/tegwick/fluid-telegram/internal/tg",
|
||||
"github.com/tegwick/fluid-telegram/internal/apply",
|
||||
"github.com/gotd/td",
|
||||
}
|
||||
for _, pkg := range []string{
|
||||
"github.com/tegwick/fluid-telegram/internal/compose",
|
||||
"github.com/tegwick/fluid-telegram/cmd/compose",
|
||||
} {
|
||||
cmd := exec.Command("go", "list", "-mod=readonly", "-deps", "-f", "{{.ImportPath}}", pkg)
|
||||
cmd.Dir = root
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("go list %s: %v\n%s", pkg, err, out)
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
for _, bad := range forbidden {
|
||||
if line == bad || strings.HasPrefix(line, bad+"/") {
|
||||
t.Errorf("%s imports %s", pkg, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func entryWith(remembered string) string {
|
||||
return `---
|
||||
id: hall-worker-ada-lens
|
||||
type: worker-entry
|
||||
display_name: "Ada Lovelace"
|
||||
status: complete
|
||||
---
|
||||
|
||||
# The lens stayed honest
|
||||
|
||||
## Who I was
|
||||
|
||||
A paragraph that must not be quoted.
|
||||
|
||||
## What I would want remembered
|
||||
|
||||
` + remembered + `
|
||||
|
||||
## Durable legacy
|
||||
|
||||
- some path that must not be quoted
|
||||
`
|
||||
}
|
||||
|
||||
func mustReplace(t *testing.T, old, new string) string {
|
||||
t.Helper()
|
||||
if !strings.Contains(fixtureEntry, old) {
|
||||
t.Fatalf("fixture lacks %q", old)
|
||||
}
|
||||
return strings.Replace(fixtureEntry, old, new, 1)
|
||||
}
|
||||
|
||||
func composedLen(t *testing.T, src Source, remembered string) int {
|
||||
t.Helper()
|
||||
d, err := Compose(entryWith(remembered), src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return utf8.RuneCountInString(d.Body)
|
||||
}
|
||||
|
||||
func openQueue(t *testing.T) *Queue {
|
||||
t.Helper()
|
||||
q, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func mustPut(t *testing.T, q *Queue) Draft {
|
||||
t.Helper()
|
||||
d, err := Compose(fixtureEntry, Source{Base: "https://example.test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.Put(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func unquote(body string) string {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
switch {
|
||||
case line == ">":
|
||||
lines = append(lines, "")
|
||||
case strings.HasPrefix(line, "> "):
|
||||
lines = append(lines, strings.TrimPrefix(line, "> "))
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func keys(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func bytesContainsField(raw []byte, field string) bool {
|
||||
return strings.Contains(string(raw), `"`+field+`"`)
|
||||
}
|
||||
142
internal/compose/publish.go
Normal file
142
internal/compose/publish.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
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
|
||||
}
|
||||
148
internal/compose/queue.go
Normal file
148
internal/compose/queue.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package compose
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Queue is the review queue. Drafts are files under a directory the caller
|
||||
// passes; this package never chooses a location of its own.
|
||||
type Queue struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// Open opens a review queue at dir, creating the directory if needed.
|
||||
func Open(dir string) (*Queue, error) {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return nil, errors.New("queue directory is required")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("%s is not a directory", dir)
|
||||
}
|
||||
return &Queue{dir: dir}, nil
|
||||
}
|
||||
|
||||
// Put stores a new draft. Review fields are cleared: approval only happens
|
||||
// through Approve, and a caller cannot smuggle a consent basis in.
|
||||
// Putting the same post_id again keeps the copy already queued.
|
||||
func (q *Queue) Put(d Draft) error {
|
||||
if _, err := q.path(d.PostID); err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := q.Get(d.PostID)
|
||||
if err == nil {
|
||||
if existing.Body != d.Body || existing.SourceEntryID != d.SourceEntryID || existing.SourceEntryURL != d.SourceEntryURL || existing.Variant != d.Variant {
|
||||
return fmt.Errorf("post_id %s is already queued with different content", d.PostID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
d.ReviewedBy = ""
|
||||
d.SubjectConsent = ""
|
||||
d.Approved = false
|
||||
d.Publication = nil
|
||||
return q.save(d)
|
||||
}
|
||||
|
||||
// Get reads one draft from the queue.
|
||||
func (q *Queue) Get(postID string) (Draft, error) {
|
||||
path, err := q.path(postID)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
var d Draft
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return Draft{}, fmt.Errorf("read draft %s: %w", postID, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Approve records the reviewer and a consent basis supplied by the caller.
|
||||
// consent is not defaulted. not-required is recorded only when the caller
|
||||
// passes that value; absence of objection is not treated as consent.
|
||||
func (q *Queue) Approve(postID, reviewer, consent string) (Draft, error) {
|
||||
reviewer = strings.TrimSpace(reviewer)
|
||||
consent = strings.TrimSpace(consent)
|
||||
if reviewer == "" {
|
||||
return Draft{}, ErrReviewer
|
||||
}
|
||||
if _, ok := consentBases[consent]; !ok {
|
||||
return Draft{}, fmt.Errorf("%w: got %q", ErrConsent, consent)
|
||||
}
|
||||
d, err := q.Get(postID)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
d.ReviewedBy = reviewer
|
||||
d.SubjectConsent = consent
|
||||
d.Approved = true
|
||||
if err := q.save(d); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (q *Queue) save(d Draft) error {
|
||||
path, err := q.path(d.PostID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := marshalDraft(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
func marshalDraft(d Draft) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (q *Queue) path(postID string) (string, error) {
|
||||
if postID == "" || strings.Contains(postID, "/") || strings.Contains(postID, "\\") || strings.Contains(postID, "..") {
|
||||
return "", fmt.Errorf("invalid post_id %q", postID)
|
||||
}
|
||||
path := filepath.Join(q.dir, postID+".json")
|
||||
absDir, err := filepath.Abs(q.dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rel, err := filepath.Rel(absDir, absPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("invalid post_id %q", postID)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
|
@ -196,7 +196,7 @@ on `FT-WP-0002` T01.
|
|||
|
||||
```task
|
||||
id: FT-WP-0001-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "317bef32-e82e-586a-8947-8cbb330f6c98"
|
||||
```
|
||||
|
|
@ -219,6 +219,12 @@ someone's mouth that their own entry never made, and in the audit trail an
|
|||
unfaithful post looks exactly like a faithful one. The reviewer reads the post
|
||||
against the entry, and their name goes in `reviewed_by`.
|
||||
|
||||
**Done.** The temporary composer is `internal/compose` (command `cmd/compose`):
|
||||
the `quoted-remembrance-v1` strategy and a review queue under a directory the
|
||||
caller passes. It does not publish without a reviewer and a caller-supplied
|
||||
consent basis, and it does not import the adapter. Tests:
|
||||
`go test ./internal/compose/... ./cmd/compose/...`.
|
||||
|
||||
## T07 — Record the consent basis for hall participants
|
||||
|
||||
```task
|
||||
|
|
@ -357,14 +363,16 @@ rather than a bot that posts to a channel.
|
|||
The loop cannot close before the experiment, the public channel, the consent
|
||||
basis (`8a095daa-35f9-4561-ae50-256558600be7`), and the operator session
|
||||
(`FT-WP-0002-T01`). T04 waits on the durable signing key
|
||||
(`d5d8a24d-d77e-4c63-ab52-b878d687e8b9`). T06 and T13 remain the source work
|
||||
that does not need those gates.
|
||||
(`d5d8a24d-d77e-4c63-ab52-b878d687e8b9`). T06 is done. T13 waits on T12; the
|
||||
temporary composer stays in this repository until the loop has closed once.
|
||||
|
||||
## T13 — Extract the campaign into pr-hall-of-helix
|
||||
|
||||
```task
|
||||
id: FT-WP-0001-T13
|
||||
status: todo
|
||||
status: wait
|
||||
depends_on: [FT-WP-0001-T12]
|
||||
blocking_reason: "Extract the campaign only after the loop has closed once (T12). The temporary composer stays here until then."
|
||||
priority: medium
|
||||
state_hub_task_id: "d471516c-2f20-525f-8d79-7814160851fb"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue