Files
2026-10-23 22:31:02 -03:00

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>"))
}