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 }