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:
tegwick 2026-09-27 23:15:29 +02:00
parent f7f5302903
commit bfebca3aee
10 changed files with 1052 additions and 6 deletions

58
cmd/adapter/main.go Normal file
View file

@ -0,0 +1,58 @@
// Command adapter runs the R-1 Telegram publishing adapter for one channel.
//
// It owns rendering and delivery only: it has no MTProto session and no
// provisioning code path, so a compromised adapter cannot create, rename,
// delete or re-permission anything (docs/adapter-contract.md). Configuration
// comes from `provision adapter-config`, per that same doc's Configuration
// section.
package main
import (
"fmt"
"log"
"net/http"
"os"
"strconv"
"github.com/tegwick/fluid-telegram/internal/adapter"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
return fmt.Errorf("TELEGRAM_BOT_TOKEN is not set; injected from OpenBao at start, see docs/adapter-contract.md")
}
chatIDStr := os.Getenv("TELEGRAM_CHANNEL_ID")
if chatIDStr == "" {
return fmt.Errorf("TELEGRAM_CHANNEL_ID is not set; run `provision adapter-config`")
}
chatID, err := strconv.ParseInt(chatIDStr, 10, 64)
if err != nil {
return fmt.Errorf("TELEGRAM_CHANNEL_ID %q is not an integer: %w", chatIDStr, err)
}
attachVisual := os.Getenv("ATTACH_VISUAL") == "true"
controlURL := os.Getenv("FLUID_CONTROL_URL")
hallBaseURL := os.Getenv("HALL_BASE_URL")
addr := os.Getenv("ADAPTER_LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}
cfg := adapter.Config{
ChatID: chatID,
AttachVisual: attachVisual,
HallBaseURL: hallBaseURL,
}
a := adapter.New(cfg, adapter.NewBotAPISender(token), adapter.NewMemStore(), adapter.NewHTTPTelemetry(controlURL))
log.Printf("adapter listening on %s (chat_id=%d, attach_visual=%v)", addr, chatID, attachVisual)
return http.ListenAndServe(addr, a.Handler())
}

316
internal/adapter/adapter.go Normal file
View file

@ -0,0 +1,316 @@
package adapter
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
)
// Telegram's own limits, per docs/adapter-contract.md.
const (
textOnlyLimit = 4096
captionLimit = 1024
)
// recognisedConsentBases mirrors contracts/r1.openapi.yaml's subject_consent
// enum. A post with none of these is refused: absence of objection is not
// consent (FT-WP-0001 T07).
var recognisedConsentBases = map[string]bool{
"explicit": true,
"standing": true,
"not-required": true,
}
// Telemetry is where the adapter emits what only it can see, per
// docs/adapter-contract.md's "Telemetry the adapter should emit". It must
// never carry subscriber identity or post body text.
type Telemetry interface {
Emit(ctx context.Context, event string, fields map[string]any)
}
// NopTelemetry discards events. Used when FLUID_CONTROL_URL is unset, e.g. in
// tests and local runs.
type NopTelemetry struct{}
func (NopTelemetry) Emit(context.Context, string, map[string]any) {}
// Config is what one adapter instance needs to publish to one channel. It is
// derived, never hand-written: `provision adapter-config` produces it (see
// internal/adaptercfg). One adapter instance serves one channel.
type Config struct {
ChatID int64
AttachVisual bool // R-2 (true) attaches a portrait; R-3 (false) is text-only
HallBaseURL string
}
// Adapter implements the three R-1 operations. It never writes to Telegram
// beyond sending to Config.ChatID, and it holds no MTProto session: see
// docs/adapter-contract.md "What the adapter must not do".
type Adapter struct {
cfg Config
sndr Sender
store Store
tel Telemetry
now func() time.Time
}
// New builds an adapter for one channel.
func New(cfg Config, sndr Sender, store Store, tel Telemetry) *Adapter {
if tel == nil {
tel = NopTelemetry{}
}
return &Adapter{cfg: cfg, sndr: sndr, store: store, tel: tel, now: time.Now}
}
// Handler wires the three endpoints exactly as contracts/r1.openapi.yaml
// names them. The gateway validates requests against that contract before the
// adapter sees them; this handler still assumes hostile content, per
// docs/adapter-contract.md.
func (a *Adapter) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/v1/channel-posts", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
a.handlePublish(w, r)
case http.MethodGet:
a.handleList(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
})
mux.HandleFunc("/v1/channel-posts/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
postID := r.URL.Path[len("/v1/channel-posts/"):]
if postID == "" {
writeError(w, http.StatusNotFound, "post_id is required")
return
}
a.handleGet(w, postID)
})
return mux
}
// postRequest mirrors the requestBody schema in contracts/r1.openapi.yaml.
type postRequest struct {
PostID string `json:"post_id"`
Body string `json:"body"`
SourceEntryID string `json:"source_entry_id"`
SourceEntryURL string `json:"source_entry_url"`
ReviewedBy string `json:"reviewed_by"`
SubjectDisplayName string `json:"subject_display_name,omitempty"`
SubjectConsent string `json:"subject_consent,omitempty"`
Variant string `json:"variant,omitempty"`
VisualURL string `json:"visual_url,omitempty"`
Tags []string `json:"tags,omitempty"`
}
func (a *Adapter) handlePublish(w http.ResponseWriter, r *http.Request) {
var req postRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if err := validateRequired(req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Refuse a post with no review or no recognised consent basis. Emitted as
// guardrail telemetry so the series exists even though it should always
// read false in practice, per docs/adapter-contract.md.
if req.ReviewedBy == "" {
a.tel.Emit(r.Context(), "unreviewed_publication", map[string]any{"post_id": req.PostID})
writeError(w, http.StatusForbidden, "reviewed_by is required")
return
}
if req.SubjectConsent != "" && !recognisedConsentBases[req.SubjectConsent] {
writeError(w, http.StatusBadRequest, fmt.Sprintf("subject_consent %q is not recognised", req.SubjectConsent))
return
}
if req.SubjectConsent == "" {
a.tel.Emit(r.Context(), "missing_subject_consent", map[string]any{"post_id": req.PostID})
writeError(w, http.StatusForbidden, "no recognised consent basis for this subject's work")
return
}
if req.SourceEntryID == "" || req.SourceEntryURL == "" {
a.tel.Emit(r.Context(), "missing_source_attribution", map[string]any{"post_id": req.PostID})
writeError(w, http.StatusBadRequest, "source_entry_id and source_entry_url are required")
return
}
limit := textOnlyLimit
if a.cfg.AttachVisual {
limit = captionLimit
}
if len(req.Body) > limit {
// A composition that does not fit was not condensed; the adapter must
// refuse rather than truncate (docs/adapter-contract.md).
writeError(w, http.StatusBadRequest,
fmt.Sprintf("body is %d characters, over the %d limit for this delivery form", len(req.Body), limit))
return
}
rendered, err := RenderHTML(req.Body)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
rendered += sourceLink(req.SourceEntryURL)
if len(rendered) > limit {
writeError(w, http.StatusBadRequest, "rendering plus source attribution exceeds the limit for this delivery form")
return
}
existing, found := a.store.Get(req.PostID)
if found {
if existing.Body == rendered {
writeJSON(w, http.StatusOK, publicationView(existing))
return
}
// Same post_id, changed content: edit rather than duplicate. A
// recomposition of the same source entry is a NEW post_id, not this
// path (docs/adapter-contract.md).
if err := a.editExisting(r.Context(), existing, rendered, req); err != nil {
a.storeFailure(req.PostID, existing, err)
writeTelegramErr(w, err)
return
}
existing.Body = rendered
existing.Variant = req.Variant
existing.SourceEntryID = req.SourceEntryID
existing.PublishedAt = a.now()
existing.State = StatePublished
existing.LastAttemptErr = ""
a.store.Put(existing)
writeJSON(w, http.StatusOK, publicationView(existing))
return
}
msgID, err := a.sendNew(r.Context(), rendered, req)
pub := Publication{
PostID: req.PostID,
Body: rendered,
SourceEntryID: req.SourceEntryID,
Variant: req.Variant,
PublishedAt: a.now(),
}
if err != nil {
pub.State = StateFailed
pub.LastAttemptErr = err.Error()
a.store.Put(pub)
writeTelegramErr(w, err)
return
}
pub.State = StatePublished
pub.TelegramMsgID = msgID
a.store.Put(pub)
a.tel.Emit(r.Context(), "published", map[string]any{"post_id": req.PostID, "variant": req.Variant})
writeJSON(w, http.StatusCreated, publicationView(pub))
}
func (a *Adapter) sendNew(ctx context.Context, rendered string, req postRequest) (int, error) {
if a.cfg.AttachVisual && req.VisualURL != "" {
return a.sndr.SendPhoto(ctx, a.cfg.ChatID, req.VisualURL, rendered)
}
return a.sndr.SendMessage(ctx, a.cfg.ChatID, rendered)
}
func (a *Adapter) editExisting(ctx context.Context, existing Publication, rendered string, req postRequest) error {
if a.cfg.AttachVisual && req.VisualURL != "" {
return a.sndr.EditMessageCaption(ctx, a.cfg.ChatID, existing.TelegramMsgID, rendered)
}
return a.sndr.EditMessageText(ctx, a.cfg.ChatID, existing.TelegramMsgID, rendered)
}
func (a *Adapter) storeFailure(postID string, existing Publication, err error) {
existing.LastAttemptErr = err.Error()
a.store.Put(existing)
_ = postID
}
func (a *Adapter) handleGet(w http.ResponseWriter, postID string) {
pub, ok := a.store.Get(postID)
if !ok {
writeError(w, http.StatusNotFound, "this post has not been published")
return
}
writeJSON(w, http.StatusOK, publicationView(pub))
}
func (a *Adapter) handleList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
filter := ListFilter{
State: State(q.Get("state")),
Variant: q.Get("variant"),
SourceEntryID: q.Get("source_entry_id"),
}
if limit := q.Get("limit"); limit != "" {
var n int
if _, err := fmt.Sscanf(limit, "%d", &n); err == nil {
filter.Limit = n
}
}
pubs := a.store.List(filter)
views := make([]map[string]any, 0, len(pubs))
for _, p := range pubs {
views = append(views, publicationView(p))
}
writeJSON(w, http.StatusOK, map[string]any{"posts": views})
}
func validateRequired(req postRequest) error {
if req.PostID == "" {
return errors.New("post_id is required")
}
if len(req.PostID) > 128 {
return errors.New("post_id exceeds 128 characters")
}
if req.Body == "" {
return errors.New("body is required")
}
return nil
}
func sourceLink(url string) string {
return fmt.Sprintf(`
<a href="%s">Source</a>`, escapeAttr(url))
}
func publicationView(p Publication) map[string]any {
return map[string]any{
"post_id": p.PostID,
"state": p.State,
"source_entry_id": p.SourceEntryID,
"variant": p.Variant,
"published_at": p.PublishedAt,
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// writeTelegramErr maps a Sender failure per docs/adapter-contract.md: a rate
// limit or any other backend failure becomes 502, never retried automatically
// for a call that may have already succeeded.
func writeTelegramErr(w http.ResponseWriter, err error) {
var rle *RateLimitError
if errors.As(err, &rle) {
w.Header().Set("Retry-After", fmt.Sprintf("%d", rle.RetryAfterSeconds))
}
writeError(w, http.StatusBadGateway, "telegram was unreachable or refused the message")
}

View file

@ -0,0 +1,228 @@
package adapter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
type fakeSender struct {
nextID int
sent []string
captions []string
edits []string
failNext error
}
func (f *fakeSender) SendMessage(_ context.Context, _ int64, html string) (int, error) {
if f.failNext != nil {
err := f.failNext
f.failNext = nil
return 0, err
}
f.nextID++
f.sent = append(f.sent, html)
return f.nextID, nil
}
func (f *fakeSender) SendPhoto(_ context.Context, _ int64, _, captionHTML string) (int, error) {
if f.failNext != nil {
err := f.failNext
f.failNext = nil
return 0, err
}
f.nextID++
f.captions = append(f.captions, captionHTML)
return f.nextID, nil
}
func (f *fakeSender) EditMessageText(_ context.Context, _ int64, _ int, html string) error {
f.edits = append(f.edits, html)
return nil
}
func (f *fakeSender) EditMessageCaption(_ context.Context, _ int64, _ int, captionHTML string) error {
f.edits = append(f.edits, captionHTML)
return nil
}
func validReq(postID string) postRequest {
return postRequest{
PostID: postID,
Body: "a short post about the work",
SourceEntryID: "entry-1",
SourceEntryURL: "https://hall.example/entry-1",
ReviewedBy: "alice",
SubjectConsent: "standing",
Variant: "v1",
}
}
func post(t *testing.T, h http.Handler, req postRequest) *httptest.ResponseRecorder {
t.Helper()
body, err := json.Marshal(req)
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodPost, "/v1/channel-posts", bytes.NewReader(body))
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func TestPublishSucceeds(t *testing.T) {
snd := &fakeSender{}
a := New(Config{ChatID: 1}, snd, NewMemStore(), nil)
w := post(t, a.Handler(), validReq("p1"))
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if len(snd.sent) != 1 {
t.Fatalf("expected one message sent, got %d", len(snd.sent))
}
}
func TestPublishRefusesWithoutReview(t *testing.T) {
snd := &fakeSender{}
a := New(Config{ChatID: 1}, snd, NewMemStore(), nil)
req := validReq("p1")
req.ReviewedBy = ""
w := post(t, a.Handler(), req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", w.Code)
}
if len(snd.sent) != 0 {
t.Fatal("expected no message sent")
}
}
func TestPublishRefusesWithoutConsentBasis(t *testing.T) {
snd := &fakeSender{}
a := New(Config{ChatID: 1}, snd, NewMemStore(), nil)
req := validReq("p1")
req.SubjectConsent = ""
w := post(t, a.Handler(), req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", w.Code)
}
}
func TestPublishRefusesUnrecognisedConsentBasis(t *testing.T) {
snd := &fakeSender{}
a := New(Config{ChatID: 1}, snd, NewMemStore(), nil)
req := validReq("p1")
req.SubjectConsent = "vibes"
w := post(t, a.Handler(), req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}
func TestPublishRefusesOverLimitForTextOnly(t *testing.T) {
snd := &fakeSender{}
a := New(Config{ChatID: 1, AttachVisual: false}, snd, NewMemStore(), nil)
req := validReq("p1")
req.Body = fmt.Sprintf("%4097s", "x")
w := post(t, a.Handler(), req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}
func TestPublishRefusesOverCaptionLimitWhenVisualAttached(t *testing.T) {
snd := &fakeSender{}
a := New(Config{ChatID: 1, AttachVisual: true}, snd, NewMemStore(), nil)
req := validReq("p1")
req.Body = fmt.Sprintf("%1025s", "x")
req.VisualURL = "https://example.com/img.png"
w := post(t, a.Handler(), req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}
func TestPublishIsIdempotentOnPostID(t *testing.T) {
snd := &fakeSender{}
a := New(Config{ChatID: 1}, snd, NewMemStore(), nil)
req := validReq("p1")
w1 := post(t, a.Handler(), req)
if w1.Code != http.StatusCreated {
t.Fatalf("first publish: status = %d", w1.Code)
}
w2 := post(t, a.Handler(), req)
if w2.Code != http.StatusOK {
t.Fatalf("repeat publish: status = %d, want 200", w2.Code)
}
if len(snd.sent) != 1 {
t.Fatalf("expected exactly one Telegram send, got %d", len(snd.sent))
}
}
func TestPublishEditsWhenSamePostIDHasNewBody(t *testing.T) {
snd := &fakeSender{}
a := New(Config{ChatID: 1}, snd, NewMemStore(), nil)
req := validReq("p1")
post(t, a.Handler(), req)
req.Body = "a revised short post about the work"
w := post(t, a.Handler(), req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if len(snd.edits) != 1 {
t.Fatalf("expected one edit, got %d", len(snd.edits))
}
if len(snd.sent) != 1 {
t.Fatalf("expected no additional send, got %d total sends", len(snd.sent))
}
}
func TestPublishMapsRateLimitTo502(t *testing.T) {
snd := &fakeSender{failNext: &RateLimitError{RetryAfterSeconds: 30}}
a := New(Config{ChatID: 1}, snd, NewMemStore(), nil)
w := post(t, a.Handler(), validReq("p1"))
if w.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", w.Code)
}
if w.Header().Get("Retry-After") != "30" {
t.Fatalf("Retry-After = %q, want 30", w.Header().Get("Retry-After"))
}
}
func TestGetPostNotFound(t *testing.T) {
a := New(Config{ChatID: 1}, &fakeSender{}, NewMemStore(), nil)
r := httptest.NewRequest(http.MethodGet, "/v1/channel-posts/nope", nil)
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
func TestListPosts(t *testing.T) {
snd := &fakeSender{}
a := New(Config{ChatID: 1}, snd, NewMemStore(), nil)
post(t, a.Handler(), validReq("p1"))
post(t, a.Handler(), validReq("p2"))
r := httptest.NewRequest(http.MethodGet, "/v1/channel-posts", nil)
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var resp struct {
Posts []map[string]any `json:"posts"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Posts) != 2 {
t.Fatalf("expected 2 posts, got %d", len(resp.Posts))
}
}

View 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, "&", "&amp;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}

View file

@ -0,0 +1,40 @@
package adapter
import "testing"
func TestRenderHTML(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"heading", "# Title\nbody", "<b>Title</b>\nbody"},
{"bold", "this is **bold** text", "this is <b>bold</b> text"},
{"italic", "this is _italic_ text", "this is <i>italic</i> text"},
{"link", "see [the entry](https://example.com/e/1)", `see <a href="https://example.com/e/1">the entry</a>`},
{"code", "```go\nfmt.Println(1)\n```", "<pre>fmt.Println(1)</pre>"},
{
"table",
"| a | b |\n|---|---|\n| 1 | 2 |",
"a — b\n\n1 — 2",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := RenderHTML(tc.in)
if err != nil {
t.Fatalf("RenderHTML(%q): %v", tc.in, err)
}
if got != tc.want {
t.Errorf("RenderHTML(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestRenderHTMLRejectsRawAngleBrackets(t *testing.T) {
_, err := RenderHTML("a <script> tag")
if err == nil {
t.Fatal("expected an error for raw angle brackets")
}
}

97
internal/adapter/store.go Normal file
View file

@ -0,0 +1,97 @@
package adapter
import (
"sort"
"sync"
"time"
)
// State is a publication's lifecycle, per contracts/r1.openapi.yaml's `state`
// enum on listPosts.
type State string
const (
StatePending State = "pending"
StatePublished State = "published"
StateFailed State = "failed"
StateSuperseded State = "superseded"
)
// Publication is this adapter's own record of a post_id: which Telegram
// message implements it. The evidence store records that a publication
// happened; this is the adapter's mapping, kept separately per
// docs/adapter-contract.md.
type Publication struct {
PostID string
State State
TelegramMsgID int
Body string // the rendered text or caption last sent, for edit-detection
SourceEntryID string
Variant string
PublishedAt time.Time
LastAttemptErr string
}
// Store is the adapter's idempotency mapping from post_id to what was sent.
// It is the adapter's own state, never fluid-core's.
type Store interface {
Get(postID string) (Publication, bool)
Put(Publication)
List(filter ListFilter) []Publication
}
// ListFilter narrows GET /v1/channel-posts, mirroring its query parameters.
type ListFilter struct {
State State
Variant string
SourceEntryID string
Limit int
}
type memStore struct {
mu sync.RWMutex
byID map[string]Publication
}
// NewMemStore is an in-process idempotency store. It is what one adapter
// instance needs for one channel; nothing here is shared with fluid-core or
// with another channel's adapter instance.
func NewMemStore() Store {
return &memStore{byID: map[string]Publication{}}
}
func (s *memStore) Get(postID string) (Publication, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.byID[postID]
return p, ok
}
func (s *memStore) Put(p Publication) {
s.mu.Lock()
defer s.mu.Unlock()
s.byID[p.PostID] = p
}
func (s *memStore) List(f ListFilter) []Publication {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Publication, 0, len(s.byID))
for _, p := range s.byID {
if f.State != "" && p.State != f.State {
continue
}
if f.Variant != "" && p.Variant != f.Variant {
continue
}
if f.SourceEntryID != "" && p.SourceEntryID != f.SourceEntryID {
continue
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PublishedAt.After(out[j].PublishedAt) })
if f.Limit > 0 && len(out) > f.Limit {
out = out[:f.Limit]
}
return out
}

View file

@ -0,0 +1,130 @@
package adapter
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
// Sender is the Telegram Bot API surface the adapter needs. The real
// implementation is one client; tests supply a fake so the adapter's request
// handling can be checked without a bot token. This is deliberately narrower
// than a full Bot API client: the adapter must not gain any capability beyond
// sending to the one chat it is configured with.
type Sender interface {
SendMessage(ctx context.Context, chatID int64, html string) (messageID int, err error)
SendPhoto(ctx context.Context, chatID int64, photoURL, captionHTML string) (messageID int, err error)
EditMessageText(ctx context.Context, chatID int64, messageID int, html string) error
EditMessageCaption(ctx context.Context, chatID int64, messageID int, captionHTML string) error
}
// RateLimitError carries Telegram's retry_after, per the 429 mapping in
// docs/adapter-contract.md: 429 -> 502 with retry_after preserved.
type RateLimitError struct {
RetryAfterSeconds int
}
func (e *RateLimitError) Error() string {
return fmt.Sprintf("rate limited by telegram, retry after %ds", e.RetryAfterSeconds)
}
// BotAPISender talks to api.telegram.org directly. Custody of the token is
// held for the duration of a call only, never logged, never echoed in an
// error.
type BotAPISender struct {
token string
hc *http.Client
}
// NewBotAPISender builds a Sender bound to one bot token. The token lives in
// this struct only; callers must not log it.
func NewBotAPISender(token string) *BotAPISender {
return &BotAPISender{
token: token,
hc: &http.Client{Timeout: 30 * time.Second},
}
}
var _ Sender = (*BotAPISender)(nil)
type apiResult struct {
OK bool `json:"ok"`
Result struct {
MessageID int `json:"message_id"`
} `json:"result"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
Parameters struct {
RetryAfter int `json:"retry_after"`
} `json:"parameters"`
}
func (b *BotAPISender) call(ctx context.Context, method string, form url.Values) (int, error) {
endpoint := fmt.Sprintf("https://api.telegram.org/bot%s/%s", b.token, method)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return 0, err
}
req.URL.RawQuery = form.Encode()
resp, err := b.hc.Do(req)
if err != nil {
// Never echo the token: err from the http package can embed the
// request URL, so this is reported without it.
return 0, fmt.Errorf("telegram %s: network failure", method)
}
defer resp.Body.Close()
var r apiResult
if decErr := json.NewDecoder(resp.Body).Decode(&r); decErr != nil {
return 0, fmt.Errorf("telegram %s: malformed response (status %d)", method, resp.StatusCode)
}
if resp.StatusCode == http.StatusTooManyRequests || r.ErrorCode == http.StatusTooManyRequests {
return 0, &RateLimitError{RetryAfterSeconds: r.Parameters.RetryAfter}
}
if !r.OK {
return 0, fmt.Errorf("telegram %s: %s (code %d)", method, r.Description, r.ErrorCode)
}
return r.Result.MessageID, nil
}
func (b *BotAPISender) SendMessage(ctx context.Context, chatID int64, html string) (int, error) {
form := url.Values{}
form.Set("chat_id", fmt.Sprintf("%d", chatID))
form.Set("text", html)
form.Set("parse_mode", "HTML")
form.Set("disable_web_page_preview", "false")
return b.call(ctx, "sendMessage", form)
}
func (b *BotAPISender) SendPhoto(ctx context.Context, chatID int64, photoURL, captionHTML string) (int, error) {
form := url.Values{}
form.Set("chat_id", fmt.Sprintf("%d", chatID))
form.Set("photo", photoURL)
form.Set("caption", captionHTML)
form.Set("parse_mode", "HTML")
return b.call(ctx, "sendPhoto", form)
}
func (b *BotAPISender) EditMessageText(ctx context.Context, chatID int64, messageID int, html string) error {
form := url.Values{}
form.Set("chat_id", fmt.Sprintf("%d", chatID))
form.Set("message_id", fmt.Sprintf("%d", messageID))
form.Set("text", html)
form.Set("parse_mode", "HTML")
_, err := b.call(ctx, "editMessageText", form)
return err
}
func (b *BotAPISender) EditMessageCaption(ctx context.Context, chatID int64, messageID int, captionHTML string) error {
form := url.Values{}
form.Set("chat_id", fmt.Sprintf("%d", chatID))
form.Set("message_id", fmt.Sprintf("%d", messageID))
form.Set("caption", captionHTML)
form.Set("parse_mode", "HTML")
_, err := b.call(ctx, "editMessageCaption", form)
return err
}

View file

@ -0,0 +1,46 @@
package adapter
import (
"bytes"
"context"
"encoding/json"
"net/http"
"time"
)
// HTTPTelemetry POSTs to the control plane, per docs/adapter-contract.md.
// It must never carry subscriber identity or post body text; callers control
// that by what they put in fields.
type HTTPTelemetry struct {
ControlURL string
hc *http.Client
}
// NewHTTPTelemetry builds a telemetry emitter posting to
// <controlURL>/control/v1/telemetry.
func NewHTTPTelemetry(controlURL string) *HTTPTelemetry {
return &HTTPTelemetry{ControlURL: controlURL, hc: &http.Client{Timeout: 10 * time.Second}}
}
func (t *HTTPTelemetry) Emit(ctx context.Context, event string, fields map[string]any) {
if t.ControlURL == "" {
return
}
payload := map[string]any{"event": event, "fields": fields}
body, err := json.Marshal(payload)
if err != nil {
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.ControlURL+"/control/v1/telemetry", bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
// Best-effort: a dropped telemetry event must never fail the publication
// it describes.
resp, err := t.hc.Do(req)
if err != nil {
return
}
_ = resp.Body.Close()
}

View file

@ -4,12 +4,12 @@ type: workplan
title: "Establish HelixForge's Telegram identity and publish the Hall of Helix"
domain: infotech
repo: fluid-telegram
status: active
status: blocked
flavor: implementation
owner: worsch
topic_slug: fluid-telegram
created: "2026-09-04"
updated: "2026-09-14"
updated: "2026-09-27"
planning_priority: high
planning_order: 1
depends_on:
@ -47,6 +47,13 @@ first experiment. See `docs/` and the handover package at
What remains needs either a person with a Telegram account or a running
adapter.
**Blocked (2026-09-27).** T05 and T09 are implemented and merged. Everything
else still open needs one of: the human session bootstrap in `FT-WP-0002` T01
(which the live provisioning apply for T01–T03 waits on), the `fluid` CLI from
`fluid-core` (T04), or a person's judgment call (T07's consent basis, T08's
editorial review, T10's publishing pace). None of that can be resolved from
this repo alone; re-open by working `FT-WP-0002` T01 first.
**T01–T03 have moved.** They were written as manual steps; they are now
provisioned from a declared specification by `FT-WP-0002`, and the three tasks
below are kept as the record of what must be true rather than as instructions to
@ -145,7 +152,7 @@ only, and a revision signed with one stops verifying after a restart.
```task
id: FT-WP-0001-T05
status: todo
status: done
priority: high
state_hub_task_id: "c89782da-9896-5957-b729-7c35165a0067"
```
@ -159,6 +166,17 @@ post arriving without `reviewed_by` or a consent basis is refused with 403; and
a post that exceeds the limit for the form being published is refused rather
than truncated. The adapter renders and delivers. It never writes.
**Done.** Go, not TypeScript — the wire contract makes the language irrelevant,
and Go keeps one toolchain for the whole repo. `internal/adapter` (`adapter.go`,
`render.go`, `store.go`, `telegram.go`, `telemetry.go`) plus `cmd/adapter`. All
three operations are implemented against `Sender`, an interface the real Bot
API client (`BotAPISender`) and the tests both satisfy — the same pattern
`internal/apply.Surface` uses for MTProto. Idempotency, the `reviewed_by` and
consent-basis refusals, the per-form length limit, and the Markdown-to-Telegram
rendering rules are covered by `internal/adapter/*_test.go`. What is not yet
exercised is a real Telegram channel: that needs a live bot token, which waits
on `FT-WP-0002` T01.
## T06 — Establish the composition step
```task
@ -234,7 +252,7 @@ automated; whether a condensation is fair to the person it describes cannot.
```task
id: FT-WP-0001-T09
status: todo
status: done
priority: medium
state_hub_task_id: "649e7df6-6c07-5cc8-a022-d1eadcdb7236"
```
@ -248,6 +266,15 @@ before either sees a subscriber.
The two arms impose different length limits on the composer, which is the one
place delivery and composition genuinely couple.
**Implemented as part of T05, not as a separate build.** `adapter.Config.
AttachVisual` selects the arm: false is R-3 (`SendMessage`, 4096-char limit),
true is R-2 (`SendPhoto` with the composed text as caption, 1024-char limit).
One adapter instance still serves one channel, per the adapter contract; which
revision it speaks is a deployment-time config choice
(`ATTACH_VISUAL`), not a code fork. Both arms are covered in
`internal/adapter/adapter_test.go`. Verifying against the private channel with
real Telegram traffic is unchanged and still waits on `FT-WP-0002` T01.
## T10 — Open the public channel and start publishing
```task

View file

@ -4,12 +4,12 @@ type: workplan
title: "Provision the Telegram presence from a declared specification"
domain: infotech
repo: fluid-telegram
status: active
status: blocked
flavor: implementation
owner: worsch
topic_slug: fluid-telegram
created: "2026-09-04"
updated: "2026-09-14"
updated: "2026-09-27"
planning_priority: high
planning_order: 2
depends_on:
@ -33,6 +33,13 @@ designated operator, and three things stay human — a phone number, the
`api_id`/`api_hash` web form, and the login code that mints the session. That is
one bounded bootstrap, after which the surface is declared and converges.
**Blocked (2026-09-27).** T02–T08 are done. T01 is the only remaining task and
it is irreducibly manual — a dedicated phone number, the `api_id`/`api_hash`
web form, and the interactive login code are things only an operator with a
Telegram account can supply; see `docs/requests/ops-warden-telegram-operator-lane.md`
for the credential-lane request. Nothing else in this workplan can proceed
until that bootstrap runs.
## T01 — Write the seeding runbook and bootstrap the session
```task