X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/fbee1a3e74297e1f73b3fc99913861b80c96cf87..80e348724041a694002fc38f57e8868b9b9a2be9:/telegram_api.go diff --git a/telegram_api.go b/telegram_api.go index bb4ee2c..3a44a0d 100644 --- a/telegram_api.go +++ b/telegram_api.go @@ -7,108 +7,238 @@ import ( "errors" "fmt" "io" + "mime/multipart" "net/http" "net/url" + "path" + "strconv" "strings" "time" ) const ( - // telegramPollSeconds is how long one getUpdates call waits for an - // update. - telegramPollSeconds = 50 + // telegramUpdateWaitTimeout is how long one getUpdates call waits for + // an update. + telegramUpdateWaitTimeout = 50 * time.Second - // telegramPollSlack is how much longer than telegramPollSeconds a + // telegramPollSlack is how much longer than telegramUpdateWaitTimeout a // getUpdates call can take before the bot gives it up. telegramPollSlack = 20 * time.Second - // telegramSendTimeout is how long a sendMessage, sendRichMessage, or - // sendChatAction call can take. A connection that stops without a - // close, such as after a network change, then fails the call, and the - // sender tries again, instead of a wait until TCP gives up. The client - // has no timeout of its own, because the long poll of getUpdates needs - // a longer limit. + // telegramPollTimeout is how long one getUpdates call can take. + telegramPollTimeout = telegramUpdateWaitTimeout + telegramPollSlack + + // telegramSendTimeout is how long a sendMessage or sendRichMessage call + // can take. A connection that stops without a close, such as after a + // network change, then fails the call, and the sender tries again, + // instead of a wait until TCP gives up. The client has no timeout of + // its own, because the long poll of getUpdates needs a longer limit. telegramSendTimeout = 30 * time.Second + // telegramFileLookupTimeout is the longest wait for media file details. + telegramFileLookupTimeout = 30 * time.Second + + // telegramFileDownloadTimeout is the longest wait for downloaded media + // bytes. + telegramFileDownloadTimeout = 30 * time.Second + + // telegramTypingTimeout is the longest wait to show the typing action. + telegramTypingTimeout = 30 * time.Second + // telegramAnswerBytes is the most bytes of one answer of the Bot API. telegramAnswerBytes = 16 << 20 ) -// errTelegramNetwork tells that a Bot API call got no valid answer, such as -// after a network error. A later call can work. -var errTelegramNetwork = errors.New("no valid answer from the Bot API") +// TelegramAPI calls the Telegram Bot API. The bot token is a part of each URL, +// so no error of TelegramAPI holds a URL, a net/http error, or the token. +type TelegramAPI struct { + base string + token string + http *http.Client +} -// newTelegramAPI returns the client of the Bot API at base with token. -func newTelegramAPI(base, token string) *TelegramAPI { - return &TelegramAPI{ - base: base, - token: token, - http: &http.Client{}, - sendTimeout: telegramSendTimeout, - } +// TelegramNetworkError tells that a Bot API call got no valid answer, such as +// after a network error. A later call can work. Cause tells why in plain words, +// with no URL and no token. +type TelegramNetworkError struct { + Cause string } -// telegramRetryable tells if a later try of a failed Bot API call can work: -// after a network error, HTTP 429, or a server error. It returns the wait that -// the Bot API asks for, or 0. -func telegramRetryable(err error) (bool, time.Duration) { - if errors.Is(err, errTelegramNetwork) { - return true, 0 - } +// TelegramError is an answer of the Bot API that tells of a failure. Its text +// gives the HTTP status and the description, such as "502 Bad Gateway". +// RetryAfter is the wait that the Bot API asks for after HTTP 429. Refused +// tells that the answer is a Bot API answer with ok false, not a page of a +// proxy or of a server error. +type TelegramError struct { + Status int + Description string + RetryAfter time.Duration + Refused bool +} - var answer *TelegramError - if errors.As(err, &answer) { - return answer.Status == http.StatusTooManyRequests || answer.Status >= 500, answer.RetryAfter - } +// TelegramUpdate is one update of getUpdates. Message is nil for the kinds of +// updates that are not a new message. +type TelegramUpdate struct { + UpdateID int64 `json:"update_id"` + Message *TelegramMessage `json:"message"` +} - return false, 0 +// TelegramEntity is a message entity of sendMessage: a part of the text with a +// format, such as bold or a link. Offset and Length count UTF-16 code units. +// URL is the target of a text_link, and Language the language of a pre. +type TelegramEntity struct { + Type string `json:"type"` + Offset int `json:"offset"` + Length int `json:"length"` + URL string `json:"url,omitempty"` + Language string `json:"language,omitempty"` } -// telegramRejected tells if the Bot API refused a call with ok false and HTTP -// 400 or 404. Then the call did nothing: 400 refuses the request, and 404 tells -// that the method is not there. An error page with no Bot API answer does not -// tell this, so it is not a refusal. -func telegramRejected(err error) bool { - var answer *TelegramError - return errors.As(err, &answer) && answer.Refused && (answer.Status == http.StatusBadRequest || answer.Status == http.StatusNotFound) +// TelegramAnswer is the envelope of each answer of the Bot API. +type TelegramAnswer struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result"` + ErrorCode int `json:"error_code"` + Description string `json:"description"` + Parameters struct { + RetryAfter int `json:"retry_after"` + } `json:"parameters"` } -// TelegramAPI calls the Telegram Bot API. The bot token is a part of each URL, -// so no error of TelegramAPI holds a URL, a net/http error, or the token. Its -// sendTimeout field limits each sendMessage, sendRichMessage, and -// sendChatAction call. -type TelegramAPI struct { - base string - token string - http *http.Client - sendTimeout time.Duration +// TelegramMessage holds text and supported media metadata of a bot message. +type TelegramMessage struct { + Chat TelegramChat `json:"chat"` + From *TelegramUser `json:"from"` + Text string `json:"text"` + Caption string `json:"caption"` + Photo []TelegramPhoto `json:"photo"` + Document *TelegramDocument `json:"document"` + Voice *TelegramVoice `json:"voice"` } -// GetUpdates waits for the updates from offset on, up to telegramPollSeconds, -// and returns them. Telegram forgets each update before offset. Only new -// messages are asked for. -func (b *TelegramAPI) GetUpdates(ctx context.Context, offset int64) ([]TelegramUpdate, error) { - ctx, cancel := context.WithTimeout(ctx, telegramPollSeconds*time.Second+telegramPollSlack) - defer cancel() +// TelegramVoice identifies a voice note in the authenticated file API. +type TelegramVoice struct { + FileID string `json:"file_id"` + FileSize int64 `json:"file_size"` +} + +// TelegramPhoto is one supplied size of a photo. +type TelegramPhoto struct { + FileID string `json:"file_id"` + Width int `json:"width"` + Height int `json:"height"` + FileSize int64 `json:"file_size"` +} + +// TelegramDocument is a file sent without photo compression. +type TelegramDocument struct { + FileID string `json:"file_id"` + MimeType string `json:"mime_type"` + FileSize int64 `json:"file_size"` +} + +// TelegramChat is the chat of a message. Type is private for a chat with one +// user. +type TelegramChat struct { + ID int64 `json:"id"` + Type string `json:"type"` +} + +// TelegramUser is the sender of a message. +type TelegramUser struct { + ID int64 `json:"id"` +} + +// newTelegramAPI returns the client of the Bot API at base with token. +func newTelegramAPI(base, token string) *TelegramAPI { + return &TelegramAPI{ + base: base, + token: token, + http: &http.Client{}, + } +} +// GetUpdates waits for the updates from offset on, up to +// telegramUpdateWaitTimeout, and returns them. Telegram forgets each update +// before offset. Only new messages are asked for. +func (b *TelegramAPI) GetUpdates(ctx context.Context, offset int64) ([]TelegramUpdate, error) { var updates []TelegramUpdate - err := b.call(ctx, "getUpdates", map[string]any{ + + err := b.call(ctx, telegramPollTimeout, "getUpdates", map[string]any{ "offset": offset, - "timeout": telegramPollSeconds, + "timeout": int(telegramUpdateWaitTimeout / time.Second), "allowed_updates": []string{"message"}, }, &updates) return updates, err } +// GetFile downloads bounded media through the authenticated file endpoint. +// Errors never include its token or URL, and the body is limited before +// storage. +func (b *TelegramAPI) GetFile(ctx context.Context, id string, limit int64, tooLarge error) (data []byte, err error) { + var file struct { + Path string `json:"file_path"` + Size int64 `json:"file_size"` + } + + err = b.call(ctx, telegramFileLookupTimeout, "getFile", map[string]any{"file_id": id}, &file) + if err != nil { + return nil, fmt.Errorf("get media file: %w", err) + } + + if file.Size > limit { + return nil, tooLarge + } + + if file.Path == "" || path.Clean(file.Path) != file.Path || strings.HasPrefix(file.Path, "/") || strings.HasPrefix(file.Path, "../") || strings.ContainsAny(file.Path, "?%#\\") { + return nil, errors.New("invalid media file path") + } + + ctx, cancel := context.WithTimeout(ctx, telegramFileDownloadTimeout) + defer cancel() + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, b.base+"/file/bot"+b.token+"/"+file.Path, nil) + if err != nil { + return nil, fmt.Errorf("make media request: %s", b.redact(err)) + } + + response, err := b.http.Do(request) + if err != nil { + return nil, b.networkError("media download failed", err, telegramFileDownloadTimeout) + } + + defer func() { + closeErr := response.Body.Close() + if closeErr != nil { + err = errors.Join(err, b.networkError("close media download", closeErr, telegramFileDownloadTimeout)) + } + }() + + if response.StatusCode != http.StatusOK { + return nil, &TelegramError{ + Status: response.StatusCode, + Description: http.StatusText(response.StatusCode), + } + } + + data, err = io.ReadAll(io.LimitReader(response.Body, limit+1)) + if err != nil { + return nil, b.networkError("media cut off", err, telegramFileDownloadTimeout) + } + + if int64(len(data)) > limit { + return nil, tooLarge + } + + return data, nil +} + // SendMessage sends text to chat with the format of entities, with no parse // mode, so the text needs no escaping. With no entities, the text is plain. A -// call that takes longer than telegramSendTimeout fails with -// errTelegramNetwork. +// call that takes longer than telegramSendTimeout fails with a +// TelegramNetworkError. func (b *TelegramAPI) SendMessage(ctx context.Context, chat int64, text string, entities []TelegramEntity) error { - ctx, cancel := context.WithTimeout(ctx, b.sendTimeout) - defer cancel() - params := map[string]any{ "chat_id": chat, "text": text, @@ -118,17 +248,14 @@ func (b *TelegramAPI) SendMessage(ctx context.Context, chat int64, text string, params["entities"] = entities } - return b.call(ctx, "sendMessage", params, nil) + return b.call(ctx, telegramSendTimeout, "sendMessage", params, nil) } // SendRichMessage sends markdown to chat as a rich message, which Telegram // renders itself, tables included. A call that takes longer than -// telegramSendTimeout fails with errTelegramNetwork. +// telegramSendTimeout fails with a TelegramNetworkError. func (b *TelegramAPI) SendRichMessage(ctx context.Context, chat int64, markdown string) error { - ctx, cancel := context.WithTimeout(ctx, b.sendTimeout) - defer cancel() - - return b.call(ctx, "sendRichMessage", map[string]any{ + return b.call(ctx, telegramSendTimeout, "sendRichMessage", map[string]any{ "chat_id": chat, "rich_message": map[string]any{ "markdown": markdown, @@ -136,21 +263,82 @@ func (b *TelegramAPI) SendRichMessage(ctx context.Context, chat int64, markdown }, nil) } -// SendTyping shows the typing action in chat for about five seconds. A call -// that takes longer than telegramSendTimeout fails with errTelegramNetwork. -func (b *TelegramAPI) SendTyping(ctx context.Context, chat int64) error { - ctx, cancel := context.WithTimeout(ctx, b.sendTimeout) +// SendImage uploads an image to the owner as a photo, or as a document when the +// Bot API refuses the photo. Network failures do not trigger a second send. +func (b *TelegramAPI) SendImage(ctx context.Context, chat int64, name string, data []byte, caption string) error { + err := b.sendImage(ctx, "sendPhoto", "photo", chat, name, data, caption) + if telegramRejected(err) { + err = b.sendImage(ctx, "sendDocument", "document", chat, name, data, caption) + } + + return err +} + +func (b *TelegramAPI) sendImage(ctx context.Context, method, field string, chat int64, name string, data []byte, caption string) error { + ctx, cancel := context.WithTimeout(ctx, telegramSendTimeout) defer cancel() - return b.call(ctx, "sendChatAction", map[string]any{ + var body bytes.Buffer + + writer := multipart.NewWriter(&body) + err := writer.WriteField("chat_id", strconv.FormatInt(chat, 10)) + if err != nil { + return fmt.Errorf("write image chat: %w", err) + } + + err = writer.WriteField("caption", caption) + if err != nil { + return fmt.Errorf("write image caption: %w", err) + } + + part, err := writer.CreateFormFile(field, name) + if err != nil { + return fmt.Errorf("make image part: %w", err) + } + + _, err = part.Write(data) + if err != nil { + return fmt.Errorf("write image part: %w", err) + } + + err = writer.Close() + if err != nil { + return fmt.Errorf("close image form: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, b.base+"/bot"+b.token+"/"+method, &body) + if err != nil { + return fmt.Errorf("make image upload: %s", b.redact(err)) + } + + request.Header.Set("Content-Type", writer.FormDataContentType()) + + err = b.answer(request, telegramSendTimeout, nil) + if err != nil { + return fmt.Errorf("upload image: %w", err) + } + + return nil +} + +// SendTyping shows the typing action in chat for about five seconds. A call +// that takes longer than telegramTypingTimeout fails with a +// TelegramNetworkError. +func (b *TelegramAPI) SendTyping(ctx context.Context, chat int64) error { + return b.call(ctx, telegramTypingTimeout, "sendChatAction", map[string]any{ "chat_id": chat, "action": "typing", }, nil) } // call sends one call of method with the JSON params and decodes its result -// into result, when result is not nil. -func (b *TelegramAPI) call(ctx context.Context, method string, params, result any) error { +// into result, when result is not nil. The call can take up to timeout. Its +// errors tell the cause in plain words, with no method name, so that a log line +// can say first what the call was for. +func (b *TelegramAPI) call(ctx context.Context, timeout time.Duration, method string, params, result any) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + body, err := json.Marshal(params) if err != nil { return fmt.Errorf("%s: encode request: %w", method, err) @@ -162,34 +350,39 @@ func (b *TelegramAPI) call(ctx context.Context, method string, params, result an } request.Header.Set("Content-Type", "application/json") + + return b.answer(request, timeout, result) +} + +// answer reads the bounded Bot API response without keeping its request URL. +func (b *TelegramAPI) answer(request *http.Request, timeout time.Duration, result any) error { response, err := b.http.Do(request) if err != nil { - return fmt.Errorf("%s: %w: %s", method, errTelegramNetwork, b.redact(err)) + return b.networkError("no answer", err, timeout) } data, err := io.ReadAll(io.LimitReader(response.Body, telegramAnswerBytes)) err = errors.Join(err, response.Body.Close()) if err != nil { - return fmt.Errorf("%s: %w: read answer: %s", method, errTelegramNetwork, b.redact(err)) + return b.networkError("answer cut off", err, timeout) } var answer TelegramAnswer + err = json.Unmarshal(data, &answer) if err != nil && response.StatusCode != http.StatusOK { return &TelegramError{ - Method: method, Status: response.StatusCode, Description: http.StatusText(response.StatusCode), } } if err != nil { - return fmt.Errorf("%s: %w: decode answer: %s", method, errTelegramNetwork, b.redact(err)) + return b.networkError("invalid JSON", err, timeout) } if !answer.OK { return &TelegramError{ - Method: method, Status: response.StatusCode, Description: b.redact(errors.New(answer.Description)), RetryAfter: time.Duration(answer.Parameters.RetryAfter) * time.Second, @@ -203,17 +396,34 @@ func (b *TelegramAPI) call(ctx context.Context, method string, params, result an err = json.Unmarshal(answer.Result, result) if err != nil { - return fmt.Errorf("%s: %w: decode result: %s", method, errTelegramNetwork, b.redact(err)) + return b.networkError("invalid result", err, timeout) } return nil } +// networkError returns the TelegramNetworkError of err, which happened at the +// step that what names. When the call used all of its time timeout, the cause +// is "no answer in TIMEOUT", because the Go text of a deadline does not tell +// how long the wait was. +func (b *TelegramAPI) networkError(what string, err error, timeout time.Duration) error { + if errors.Is(err, context.DeadlineExceeded) { + return &TelegramNetworkError{ + Cause: "no answer in " + timeout.String(), + } + } + + return &TelegramNetworkError{ + Cause: what + ": " + b.redact(err), + } +} + // redact returns the text of err without a URL and without the token. A // *url.Error holds the URL of the call, which holds the token, so only its // cause is kept. func (b *TelegramAPI) redact(err error) string { var urlError *url.Error + if errors.As(err, &urlError) { err = urlError.Err } @@ -221,67 +431,39 @@ func (b *TelegramAPI) redact(err error) string { return strings.ReplaceAll(err.Error(), b.token, "TOKEN") } -// TelegramError is an answer of the Bot API that tells of a failure. RetryAfter -// is the wait that the Bot API asks for after HTTP 429. Refused tells that the -// answer is a Bot API answer with ok false, not a page of a proxy or of a -// server error. -type TelegramError struct { - Method string - Status int - Description string - RetryAfter time.Duration - Refused bool +func (e *TelegramNetworkError) Error() string { + return e.Cause } func (e *TelegramError) Error() string { - return fmt.Sprintf("%s: Bot API answered %d: %s", e.Method, e.Status, e.Description) + return fmt.Sprintf("%d %s", e.Status, e.Description) } -// TelegramUpdate is one update of getUpdates. Message is nil for the kinds of -// updates that are not a new message. -type TelegramUpdate struct { - UpdateID int64 `json:"update_id"` - Message *TelegramMessage `json:"message"` -} +// telegramRetryable tells if a later try of a failed Bot API call can work: +// after a network error, HTTP 429, or a server error. It returns the wait that +// the Bot API asks for, or 0. +func telegramRetryable(err error) (bool, time.Duration) { + var network *TelegramNetworkError -// TelegramEntity is a message entity of sendMessage: a part of the text with a -// format, such as bold or a link. Offset and Length count UTF-16 code units. -// URL is the target of a text_link, and Language the language of a pre. -type TelegramEntity struct { - Type string `json:"type"` - Offset int `json:"offset"` - Length int `json:"length"` - URL string `json:"url,omitempty"` - Language string `json:"language,omitempty"` -} + if errors.As(err, &network) { + return true, 0 + } -// TelegramAnswer is the envelope of each answer of the Bot API. -type TelegramAnswer struct { - OK bool `json:"ok"` - Result json.RawMessage `json:"result"` - ErrorCode int `json:"error_code"` - Description string `json:"description"` - Parameters struct { - RetryAfter int `json:"retry_after"` - } `json:"parameters"` -} + var answer *TelegramError -// TelegramMessage is a message to the bot. Text is empty for a message that is -// not text, such as a photo. -type TelegramMessage struct { - Chat TelegramChat `json:"chat"` - From *TelegramUser `json:"from"` - Text string `json:"text"` -} + if errors.As(err, &answer) { + return answer.Status == http.StatusTooManyRequests || answer.Status >= 500, answer.RetryAfter + } -// TelegramChat is the chat of a message. Type is private for a chat with one -// user. -type TelegramChat struct { - ID int64 `json:"id"` - Type string `json:"type"` + return false, 0 } -// TelegramUser is the sender of a message. -type TelegramUser struct { - ID int64 `json:"id"` +// telegramRejected tells if the Bot API refused a call with ok false and HTTP +// 400 or 404. Then the call did nothing: 400 refuses the request, and 404 tells +// that the method is not there. An error page with no Bot API answer does not +// tell this, so it is not a refusal. +func telegramRejected(err error) bool { + var answer *TelegramError + + return errors.As(err, &answer) && answer.Refused && (answer.Status == http.StatusBadRequest || answer.Status == http.StatusNotFound) }