Implement the R-1 adapter (FT-WP-0001 T05/T09); mark workplans blocked
Builds internal/adapter and cmd/adapter: the three contracts/r1.openapi.yaml operations, idempotency on post_id, the reviewed_by/consent-basis refusals, per-form (R-2/R-3) length limits, and Markdown-to-Telegram rendering, all against a Sender interface so it's testable without a live bot token. Every other open task in FT-WP-0001 and FT-WP-0002 needs something this repo can't supply on its own: the human session bootstrap in FT-WP-0002 T01, the fluid CLI from fluid-core, or a person's judgment (consent basis, editorial review, publishing pace). Set both workplans to status: blocked and flagged FT-WP-0002-T01 needs_human on the hub, since it's the single blocker gating everything else. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 278411@bnt-lap001 Assistant-Session: 19568045-15bc-4b88-ba3d-8e95b4f9c6f1
This commit is contained in:
parent
f7f5302903
commit
bfebca3aee
10 changed files with 1052 additions and 6 deletions
97
internal/adapter/render.go
Normal file
97
internal/adapter/render.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// Package adapter implements the R-1 connector contract
|
||||
// (contracts/r1.openapi.yaml): an ordinary HTTP service that renders a
|
||||
// composed post into a Telegram message and never writes to Telegram beyond
|
||||
// that.
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrUnrenderable marks a post construct the converter cannot represent
|
||||
// faithfully. Per docs/adapter-contract.md, that fails the publication rather
|
||||
// than degrading it silently.
|
||||
type ErrUnrenderable struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *ErrUnrenderable) Error() string {
|
||||
return fmt.Sprintf("cannot render post: %s", e.Reason)
|
||||
}
|
||||
|
||||
var (
|
||||
reHeading = regexp.MustCompile(`(?m)^#{1,6}[ \t]+(.+)$`)
|
||||
reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
|
||||
reItalic = regexp.MustCompile(`_(.+?)_`)
|
||||
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||
reFence = regexp.MustCompile("(?s)```[a-zA-Z0-9]*\n(.*?)```")
|
||||
reTableRow = regexp.MustCompile(`(?m)^\|.*\|[ \t]*$`)
|
||||
)
|
||||
|
||||
// RenderHTML converts a composed post's Markdown into Telegram's HTML parse
|
||||
// mode, per the table in docs/adapter-contract.md. It is deterministic: the
|
||||
// adapter must never compose, rewrite, shorten or embellish the text it is
|
||||
// given, only translate its markup.
|
||||
func RenderHTML(body string) (string, error) {
|
||||
if strings.Contains(body, "<") || strings.Contains(body, ">") {
|
||||
return "", &ErrUnrenderable{Reason: "body contains raw angle brackets, which HTML parse mode would interpret as tags"}
|
||||
}
|
||||
|
||||
s := body
|
||||
|
||||
// Fenced code -> <pre>. Must run before bold/italic so markup inside a
|
||||
// code block is preserved literally, not reinterpreted.
|
||||
s = reFence.ReplaceAllStringFunc(s, func(m string) string {
|
||||
inner := reFence.FindStringSubmatch(m)[1]
|
||||
return "<pre>" + strings.TrimRight(inner, "\n") + "</pre>"
|
||||
})
|
||||
|
||||
// Tables have no Telegram equivalent; flatten each row to a plain line.
|
||||
s = reTableRow.ReplaceAllStringFunc(s, func(row string) string {
|
||||
cells := strings.Split(strings.Trim(strings.TrimSpace(row), "|"), "|")
|
||||
if isSeparatorRow(cells) {
|
||||
return ""
|
||||
}
|
||||
for i, c := range cells {
|
||||
cells[i] = strings.TrimSpace(c)
|
||||
}
|
||||
return strings.Join(cells, " — ")
|
||||
})
|
||||
s = strings.ReplaceAll(s, "\n\n\n", "\n\n") // collapse a blank line left by a stripped separator row
|
||||
|
||||
// Headings -> a bold line.
|
||||
s = reHeading.ReplaceAllString(s, "<b>$1</b>")
|
||||
|
||||
// Links before bold/italic, since link text may itself carry emphasis
|
||||
// markers that must not be double-escaped.
|
||||
s = reLink.ReplaceAllStringFunc(s, func(m string) string {
|
||||
g := reLink.FindStringSubmatch(m)
|
||||
return fmt.Sprintf(`<a href="%s">%s</a>`, escapeAttr(g[2]), g[1])
|
||||
})
|
||||
|
||||
s = reBoldStar.ReplaceAllString(s, "<b>$1</b>")
|
||||
s = reItalic.ReplaceAllString(s, "<i>$1</i>")
|
||||
|
||||
return strings.TrimSpace(s), nil
|
||||
}
|
||||
|
||||
func isSeparatorRow(cells []string) bool {
|
||||
for _, c := range cells {
|
||||
c = strings.TrimSpace(c)
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Trim(c, "-: ") != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func escapeAttr(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return s
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue