fluid-telegram/internal/adapter/telegram.go
tegwick 86cbb43965 FT-WP-0001-T14: upload post images instead of linking them
The adapter fetches visual_url from an allowlisted host (https, image types,
10 MB cap, optional read token) and uploads it as multipart sendPhoto, so the
forge can close without breaking post images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5457@bnt-lap001
Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012
2026-10-08 20:55:32 +02:00

178 lines
6.2 KiB
Go

package adapter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/textproto"
"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)
UploadPhoto(ctx context.Context, chatID int64, photo Photo, 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) endpoint(method string) string {
return fmt.Sprintf("https://api.telegram.org/bot%s/%s", b.token, method)
}
func (b *BotAPISender) call(ctx context.Context, method string, form url.Values) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.endpoint(method), nil)
if err != nil {
return 0, fmt.Errorf("telegram %s: request could not be built", method)
}
req.URL.RawQuery = form.Encode()
return b.do(method, req)
}
// 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) {
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)
}
// 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)
}
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
}