293 lines
9.6 KiB
Go
293 lines
9.6 KiB
Go
// 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[:])
|
|
}
|