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"
|
title: "Establish HelixForge's Telegram identity and publish the Hall of Helix"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: fluid-telegram
|
repo: fluid-telegram
|
||||||
status: active
|
status: blocked
|
||||||
flavor: implementation
|
flavor: implementation
|
||||||
owner: worsch
|
owner: worsch
|
||||||
topic_slug: fluid-telegram
|
topic_slug: fluid-telegram
|
||||||
created: "2026-09-04"
|
created: "2026-09-04"
|
||||||
updated: "2026-09-14"
|
updated: "2026-09-27"
|
||||||
planning_priority: high
|
planning_priority: high
|
||||||
planning_order: 1
|
planning_order: 1
|
||||||
depends_on:
|
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
|
What remains needs either a person with a Telegram account or a running
|
||||||
adapter.
|
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
|
**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
|
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
|
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
|
```task
|
||||||
id: FT-WP-0001-T05
|
id: FT-WP-0001-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c89782da-9896-5957-b729-7c35165a0067"
|
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
|
a post that exceeds the limit for the form being published is refused rather
|
||||||
than truncated. The adapter renders and delivers. It never writes.
|
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
|
## T06 — Establish the composition step
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -234,7 +252,7 @@ automated; whether a condensation is fair to the person it describes cannot.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: FT-WP-0001-T09
|
id: FT-WP-0001-T09
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "649e7df6-6c07-5cc8-a022-d1eadcdb7236"
|
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
|
The two arms impose different length limits on the composer, which is the one
|
||||||
place delivery and composition genuinely couple.
|
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
|
## T10 — Open the public channel and start publishing
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ type: workplan
|
||||||
title: "Provision the Telegram presence from a declared specification"
|
title: "Provision the Telegram presence from a declared specification"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: fluid-telegram
|
repo: fluid-telegram
|
||||||
status: active
|
status: blocked
|
||||||
flavor: implementation
|
flavor: implementation
|
||||||
owner: worsch
|
owner: worsch
|
||||||
topic_slug: fluid-telegram
|
topic_slug: fluid-telegram
|
||||||
created: "2026-09-04"
|
created: "2026-09-04"
|
||||||
updated: "2026-09-14"
|
updated: "2026-09-27"
|
||||||
planning_priority: high
|
planning_priority: high
|
||||||
planning_order: 2
|
planning_order: 2
|
||||||
depends_on:
|
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
|
`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.
|
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
|
## T01 — Write the seeding runbook and bootstrap the session
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue