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

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