From 94ee3f4f9ca6d426deba075f83b7f50fdccb9257 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 5 Oct 2026 02:50:27 +0200 Subject: [PATCH] FT-WP-0001-T06: add the temporary quoted-remembrance composer. Assistant: grok Assistant-Session: 01a1097f-0521-7fd1-8343-23631f633e2e --- cmd/compose/main.go | 135 ++++ cmd/compose/main_test.go | 117 ++++ internal/compose/compose.go | 293 +++++++++ internal/compose/compose_test.go | 591 ++++++++++++++++++ internal/compose/publish.go | 142 +++++ internal/compose/queue.go | 148 +++++ ...0001-telegram-identity-and-hall-channel.md | 16 +- 7 files changed, 1438 insertions(+), 4 deletions(-) create mode 100644 cmd/compose/main.go create mode 100644 cmd/compose/main_test.go create mode 100644 internal/compose/compose.go create mode 100644 internal/compose/compose_test.go create mode 100644 internal/compose/publish.go create mode 100644 internal/compose/queue.go diff --git a/cmd/compose/main.go b/cmd/compose/main.go new file mode 100644 index 0000000..17813db --- /dev/null +++ b/cmd/compose/main.go @@ -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 --queue (--source-url | --base-url ) + compose approve --queue --post --reviewer --consent explicit|standing|not-required + compose publish --queue --post --adapter + +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/ 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 +} diff --git a/cmd/compose/main_test.go b/cmd/compose/main_test.go new file mode 100644 index 0000000..1812e1c --- /dev/null +++ b/cmd/compose/main_test.go @@ -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 +} diff --git a/internal/compose/compose.go b/internal/compose/compose.go new file mode 100644 index 0000000..9afe689 --- /dev/null +++ b/internal/compose/compose.go @@ -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/ 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[:]) +} diff --git a/internal/compose/compose_test.go b/internal/compose/compose_test.go new file mode 100644 index 0000000..c7a0e1d --- /dev/null +++ b/internal/compose/compose_test.go @@ -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+`"`) +} diff --git a/internal/compose/publish.go b/internal/compose/publish.go new file mode 100644 index 0000000..9b7c952 --- /dev/null +++ b/internal/compose/publish.go @@ -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 +} diff --git a/internal/compose/queue.go b/internal/compose/queue.go new file mode 100644 index 0000000..584d2e0 --- /dev/null +++ b/internal/compose/queue.go @@ -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 +} diff --git a/workplans/FT-WP-0001-telegram-identity-and-hall-channel.md b/workplans/FT-WP-0001-telegram-identity-and-hall-channel.md index 21603a1..1c5e57f 100644 --- a/workplans/FT-WP-0001-telegram-identity-and-hall-channel.md +++ b/workplans/FT-WP-0001-telegram-identity-and-hall-channel.md @@ -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" ```