// 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 ->
. 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 "
" + strings.TrimRight(inner, "\n") + "
" }) // 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, "$1") // 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(`%s`, escapeAttr(g[2]), g[1]) }) s = reBoldStar.ReplaceAllString(s, "$1") s = reItalic.ReplaceAllString(s, "$1") 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 }