// 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[:]) }