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