// 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"` }