From 45231ba69e53a32da510d177190ae15ae8f10b64 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 20:52:34 +0400 Subject: [PATCH] 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. --- internal/delivery/telegramsink/botapi.go | 175 +++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 internal/delivery/telegramsink/botapi.go diff --git a/internal/delivery/telegramsink/botapi.go b/internal/delivery/telegramsink/botapi.go new file mode 100644 index 0000000..c4cbb79 --- /dev/null +++ b/internal/delivery/telegramsink/botapi.go @@ -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"` +}