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
2026-09-27 23:15:29 +02:00
|
|
|
package adapter
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-08 20:55:32 +02:00
|
|
|
"bytes"
|
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
2026-09-27 23:15:29 +02:00
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
2026-10-08 20:55:32 +02:00
|
|
|
"mime/multipart"
|
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
2026-09-27 23:15:29 +02:00
|
|
|
"net/http"
|
2026-10-08 20:55:32 +02:00
|
|
|
"net/textproto"
|
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
2026-09-27 23:15:29 +02:00
|
|
|
"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)
|
2026-10-08 20:55:32 +02:00
|
|
|
UploadPhoto(ctx context.Context, chatID int64, photo Photo, captionHTML string) (messageID int, err error)
|
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
2026-09-27 23:15:29 +02:00
|
|
|
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"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 20:55:32 +02:00
|
|
|
func (b *BotAPISender) endpoint(method string) string {
|
|
|
|
|
return fmt.Sprintf("https://api.telegram.org/bot%s/%s", b.token, method)
|
|
|
|
|
}
|
|
|
|
|
|
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
2026-09-27 23:15:29 +02:00
|
|
|
func (b *BotAPISender) call(ctx context.Context, method string, form url.Values) (int, error) {
|
2026-10-08 20:55:32 +02:00
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.endpoint(method), nil)
|
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
2026-09-27 23:15:29 +02:00
|
|
|
if err != nil {
|
2026-10-08 20:55:32 +02:00
|
|
|
return 0, fmt.Errorf("telegram %s: request could not be built", method)
|
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
2026-09-27 23:15:29 +02:00
|
|
|
}
|
|
|
|
|
req.URL.RawQuery = form.Encode()
|
2026-10-08 20:55:32 +02:00
|
|
|
return b.do(method, req)
|
|
|
|
|
}
|
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
2026-09-27 23:15:29 +02:00
|
|
|
|
2026-10-08 20:55:32 +02:00
|
|
|
// do sends a prepared request and maps the Bot API reply. Errors never carry
|
|
|
|
|
// the request URL, which holds the token.
|
|
|
|
|
func (b *BotAPISender) do(method string, req *http.Request) (int, error) {
|
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
2026-09-27 23:15:29 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 20:55:32 +02:00
|
|
|
// UploadPhoto sends the image bytes as multipart/form-data, so Telegram never
|
|
|
|
|
// needs to reach the image's host.
|
|
|
|
|
func (b *BotAPISender) UploadPhoto(ctx context.Context, chatID int64, photo Photo, captionHTML string) (int, error) {
|
|
|
|
|
var body bytes.Buffer
|
|
|
|
|
mw := multipart.NewWriter(&body)
|
|
|
|
|
fields := [][2]string{
|
|
|
|
|
{"chat_id", fmt.Sprintf("%d", chatID)},
|
|
|
|
|
{"caption", captionHTML},
|
|
|
|
|
{"parse_mode", "HTML"},
|
|
|
|
|
}
|
|
|
|
|
for _, f := range fields {
|
|
|
|
|
if err := mw.WriteField(f[0], f[1]); err != nil {
|
|
|
|
|
return 0, fmt.Errorf("telegram sendPhoto: form could not be built")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
h := make(textproto.MIMEHeader)
|
|
|
|
|
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="photo"; filename=%q`, photo.Filename))
|
|
|
|
|
h.Set("Content-Type", photo.ContentType)
|
|
|
|
|
part, err := mw.CreatePart(h)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("telegram sendPhoto: form could not be built")
|
|
|
|
|
}
|
|
|
|
|
if _, err := part.Write(photo.Data); err != nil {
|
|
|
|
|
return 0, fmt.Errorf("telegram sendPhoto: form could not be built")
|
|
|
|
|
}
|
|
|
|
|
if err := mw.Close(); err != nil {
|
|
|
|
|
return 0, fmt.Errorf("telegram sendPhoto: form could not be built")
|
|
|
|
|
}
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.endpoint("sendPhoto"), &body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("telegram sendPhoto: request could not be built")
|
|
|
|
|
}
|
|
|
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
|
|
|
return b.do("sendPhoto", req)
|
|
|
|
|
}
|
|
|
|
|
|
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
2026-09-27 23:15:29 +02:00
|
|
|
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
|
|
|
|
|
}
|