45231ba69e
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.
176 lines
5.2 KiB
Go
176 lines
5.2 KiB
Go
// 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"`
|
|
}
|