166 lines
4.1 KiB
Go
166 lines
4.1 KiB
Go
// Package telegram é um cliente mínimo da Bot API (long polling e envio de
|
|
// mensagens), portado do Modelo. Não usa bibliotecas externas.
|
|
package telegram
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"botpontos/internal/domain"
|
|
)
|
|
|
|
const apiBase = "https://api.telegram.org"
|
|
|
|
type Client struct {
|
|
token string
|
|
http *http.Client
|
|
base string
|
|
}
|
|
|
|
// NewClient aceita token vazio: nesse caso Send devolve domain.ErrNotConfigured.
|
|
func NewClient(token string) *Client {
|
|
return &Client{token: strings.TrimSpace(token), http: &http.Client{Timeout: 40 * time.Second}, base: apiBase}
|
|
}
|
|
|
|
func (c *Client) Configured() bool { return c.token != "" }
|
|
|
|
type Update struct {
|
|
UpdateID int64 `json:"update_id"`
|
|
Message *Message `json:"message"`
|
|
}
|
|
|
|
type Message struct {
|
|
Chat Chat `json:"chat"`
|
|
From *From `json:"from"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
type Chat struct {
|
|
ID int64 `json:"id"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type From struct {
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
// Name é o nome de exibição usado no cadastro.
|
|
func (f *From) Name() string {
|
|
if f == nil {
|
|
return "Sem nome"
|
|
}
|
|
name := strings.TrimSpace(f.FirstName + " " + f.LastName)
|
|
if name == "" {
|
|
name = f.Username
|
|
}
|
|
if name == "" {
|
|
name = "Sem nome"
|
|
}
|
|
return name
|
|
}
|
|
|
|
type response[T any] struct {
|
|
OK bool `json:"ok"`
|
|
Result T `json:"result"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
func (r *response[T]) status() (bool, string) { return r.OK, r.Description }
|
|
|
|
type sendResult struct {
|
|
MessageID int64 `json:"message_id"`
|
|
}
|
|
|
|
// apiResponse é satisfeita por *response[T] de qualquer tipo.
|
|
type apiResponse interface {
|
|
status() (ok bool, description string)
|
|
}
|
|
|
|
// GetUpdates faz long polling por até timeout segundos.
|
|
func (c *Client) GetUpdates(ctx context.Context, offset int64, timeout int) ([]Update, error) {
|
|
if !c.Configured() {
|
|
return nil, domain.ErrNotConfigured
|
|
}
|
|
query := url.Values{}
|
|
query.Set("timeout", strconv.Itoa(timeout))
|
|
query.Set("offset", strconv.FormatInt(offset, 10))
|
|
query.Set("allowed_updates", `["message"]`)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("getUpdates")+"?"+query.Encode(), nil)
|
|
if err != nil {
|
|
return nil, c.redact(err)
|
|
}
|
|
var body response[[]Update]
|
|
if err := c.do(req, &body); err != nil {
|
|
return nil, fmt.Errorf("telegram getUpdates: %w", err)
|
|
}
|
|
return body.Result, nil
|
|
}
|
|
|
|
// Send envia uma mensagem de texto e devolve o message_id.
|
|
func (c *Client) Send(ctx context.Context, chatID int64, text string) (int64, error) {
|
|
if !c.Configured() {
|
|
return 0, domain.ErrNotConfigured
|
|
}
|
|
form := url.Values{}
|
|
form.Set("chat_id", strconv.FormatInt(chatID, 10))
|
|
form.Set("text", text)
|
|
form.Set("disable_web_page_preview", "true")
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("sendMessage"), strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return 0, c.redact(err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
var body response[sendResult]
|
|
if err := c.do(req, &body); err != nil {
|
|
return 0, fmt.Errorf("telegram sendMessage: %w", err)
|
|
}
|
|
return body.Result.MessageID, nil
|
|
}
|
|
|
|
func (c *Client) do(req *http.Request, out apiResponse) error {
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return c.redact(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
return fmt.Errorf("HTTP %d com resposta ilegível", resp.StatusCode)
|
|
}
|
|
if ok, description := out.status(); !ok {
|
|
if description == "" {
|
|
description = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
|
}
|
|
return errors.New(description)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) endpoint(method string) string {
|
|
return c.base + "/bot" + c.token + "/" + method
|
|
}
|
|
|
|
// redact evita que o token (que faz parte da URL) apareça em logs e auditoria.
|
|
func (c *Client) redact(err error) error {
|
|
var urlErr *url.Error
|
|
if errors.As(err, &urlErr) {
|
|
err = urlErr.Err
|
|
}
|
|
if c.token == "" {
|
|
return err
|
|
}
|
|
return errors.New(strings.ReplaceAll(err.Error(), c.token, "<token>"))
|
|
}
|