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
58
cmd/adapter/main.go
Normal file
58
cmd/adapter/main.go
Normal 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
316
internal/adapter/adapter.go
Normal 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")
|
||||
}
|
||||
228
internal/adapter/adapter_test.go
Normal file
228
internal/adapter/adapter_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
40
internal/adapter/render_test.go
Normal file
40
internal/adapter/render_test.go
Normal 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
97
internal/adapter/store.go
Normal 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
|
||||
}
|
||||
130
internal/adapter/telegram.go
Normal file
130
internal/adapter/telegram.go
Normal 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
|
||||
}
|
||||
46
internal/adapter/telemetry.go
Normal file
46
internal/adapter/telemetry.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue