fluid-telegram/internal/adapter/telegram.go
tegwick bfebca3aee 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:37 +02:00

130 lines
4.3 KiB
Go

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
}