the bot API calls the inbound half makes (V-637)

getUpdates, sendMessage, answerCallbackQuery and editMessageReplyMarkup,
plus the inbound shapes cut to what the poller reads. Every error goes
through the sink's redaction: the token is in the URL path because telegram
accepts it nowhere else, and net/http prints that URL on a transport
failure.

Only ok=true is a success, the same rule the push half already applies. A
relay that is up but cannot reach api.telegram.org answers 200 with an HTML
page of its own, and reading that as a batch of updates would be silent.

A chat id arrives as a number for a user and a string for a channel, so it
is held as json.Number and never converted.
This commit is contained in:
2026-08-06 20:52:34 +04:00
parent 42c7b8b927
commit 45231ba69e
+175
View File
@@ -0,0 +1,175 @@
// botapi.go — the telegram bot API calls the intake half makes, and the inbound
// shapes it reads (V-637). Split out of intake.go so the poller reads as the
// policy it is, with the wire in one place under it.
package telegramsink
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
)
// getUpdates long-polls. The offset is telegram's own acknowledgement: asking
// for lastSeen+1 is what drops everything before it from the queue, so an
// update is handled once even across a restart.
func (p *Poller) getUpdates(ctx context.Context, timeoutSec int) ([]update, error) {
body, err := json.Marshal(map[string]any{
"offset": p.offset,
"timeout": timeoutSec,
"allowed_updates": []string{"message", "callback_query"},
})
if err != nil {
return nil, err
}
var env struct {
telegramResp
Result []update `json:"result"`
}
if err := p.call(ctx, "getUpdates", body, &env); err != nil {
return nil, err
}
for _, u := range env.Result {
if u.UpdateID >= p.offset {
p.offset = u.UpdateID + 1
}
}
return env.Result, nil
}
func (p *Poller) send(ctx context.Context, text string, kb *inlineKeyboard) error {
body, err := json.Marshal(sendMessageReq{
ChatID: p.cfgChatID(),
Text: text,
// A reply to something he just typed is not an alarm, but it is still his
// own data in a third party's chat, so it stays unforwardable like the
// away messages the sink pushes.
ProtectContent: true,
ReplyMarkup: kb,
})
if err != nil {
return err
}
return p.call(ctx, "sendMessage", body, nil)
}
// answerCallback stops the clock on the tapped button. text empty is a silent
// acknowledgement; anything else shows as a toast.
func (p *Poller) answerCallback(ctx context.Context, id, text string) {
body, err := json.Marshal(map[string]any{"callback_query_id": id, "text": text})
if err != nil {
return
}
if err := p.call(ctx, "answerCallbackQuery", body, nil); err != nil {
log.Printf("telegram intake: answer callback: %v", err)
}
}
// editKeyboard replaces the buttons under a message the bot sent. kb nil takes
// them off.
func (p *Poller) editKeyboard(ctx context.Context, chatID string, messageID int64, kb *inlineKeyboard) error {
payload := map[string]any{"chat_id": chatID, "message_id": messageID}
if kb != nil {
payload["reply_markup"] = kb
} else {
payload["reply_markup"] = inlineKeyboard{Rows: [][]inlineButton{}}
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
return p.call(ctx, "editMessageReplyMarkup", body, nil)
}
// call posts one bot API method and checks the envelope. out may be nil when
// only the ok flag matters. Every error goes through the sink's redaction: the
// token is in the URL path because telegram accepts it nowhere else, and
// net/http prints that URL in transport errors.
func (p *Poller) call(ctx context.Context, method string, body []byte, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
p.sink.base+"/bot"+p.sink.cfg.BotToken+"/"+method, bytes.NewReader(body))
if err != nil {
return p.sink.redact(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.hc.Do(req)
if err != nil {
return fmt.Errorf("telegramsink: %s: %w", method, p.sink.redact(err))
}
defer resp.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, maxIntakeRespBytes))
var tr telegramResp
if err := json.Unmarshal(rb, &tr); err != nil {
return fmt.Errorf("telegramsink: %s: %d with a body that is not the bot API envelope: %s",
method, resp.StatusCode, snippet(rb))
}
if !tr.Ok {
return fmt.Errorf("telegramsink: %s: telegram returned error %d: %s",
method, tr.ErrorCode, strings.TrimSpace(tr.Description))
}
if out == nil {
return nil
}
if err := json.Unmarshal(rb, out); err != nil {
return fmt.Errorf("telegramsink: %s: decode result: %w", method, err)
}
return nil
}
// maxIntakeRespBytes — a getUpdates batch carries up to 100 messages, so the
// send path's cap is too small here. Still bounded: the body is wire-controlled
// and a relay sits in front of it.
const maxIntakeRespBytes = 4 << 20
// The inbound shapes, cut to what the poller reads.
type update struct {
UpdateID int64 `json:"update_id"`
Message *message `json:"message,omitempty"`
CallbackQuery *callbackQuery `json:"callback_query,omitempty"`
}
type message struct {
MessageID int64 `json:"message_id"`
Chat chat `json:"chat"`
Text string `json:"text"`
}
type callbackQuery struct {
ID string `json:"id"`
Data string `json:"data"`
Message message `json:"message"`
}
// chat — the id arrives as a JSON number for a user and a string for a channel,
// and the config holds whichever was written. json.Number keeps both without
// choosing.
type chat struct {
ID json.Number `json:"id"`
Username string `json:"username,omitempty"`
}
func (c chat) idString() string {
if s := c.ID.String(); s != "" {
return s
}
if c.Username != "" {
return "@" + c.Username
}
return ""
}
// inlineKeyboard — the reply_markup shape. Rows of buttons, each carrying
// callback data.
type inlineKeyboard struct {
Rows [][]inlineButton `json:"inline_keyboard"`
}
type inlineButton struct {
Text string `json:"text"`
Data string `json:"callback_data"`
}