Files
bot-pontos/backend/internal/adapters/pontomais/client.go
2026-10-23 22:31:02 -03:00

291 lines
8.1 KiB
Go

// Package pontomais é o adapter de saída para a API da PontoMais.
//
// Contratos confirmados no Modelo: POST /api/auth/sign_in devolve token e
// client_id; GET /api/time_cards/current/last_cached devolve os registros
// recentes. A API não informa a validade do token: a sessão fica só em
// memória e é renovada quando o histórico responde 401/403.
package pontomais
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"botpontos/internal/domain"
)
var (
// ErrAuthRejected: a PontoMais recusou o login (credencial inválida ou bloqueada).
ErrAuthRejected = errors.New("a PontoMais recusou a credencial")
)
// Decrypter decifra a senha guardada; satisfeito por *crypto.Keyring.
type Decrypter interface {
Decrypt(data []byte, version int16) (string, error)
}
type Client struct {
http *http.Client
authURL string
historyURL string
keys Decrypter
retryDelay time.Duration
mu sync.Mutex
sessions map[int64]cachedSession
}
type session struct {
client string
token string
uid string
}
type cachedSession struct {
session session
credentialsUpdated time.Time
}
func NewClient(authURL, historyURL string, timeout time.Duration, keys Decrypter) *Client {
return &Client{
http: &http.Client{Timeout: timeout},
authURL: authURL,
historyURL: historyURL,
keys: keys,
retryDelay: 3 * time.Second,
sessions: make(map[int64]cachedSession),
}
}
// History devolve os pontos recentes do usuário. Faz uma nova tentativa rápida
// em falhas transitórias (rede, timeout, 5xx) e refaz o login uma vez após 401/403.
func (c *Client) History(ctx context.Context, cred domain.Credential, loc *time.Location) ([]domain.Point, error) {
points, err := c.historyOnce(ctx, cred, loc)
if err == nil || !isTransient(err) {
return points, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(c.retryDelay):
}
return c.historyOnce(ctx, cred, loc)
}
func (c *Client) historyOnce(ctx context.Context, cred domain.Credential, loc *time.Location) ([]domain.Point, error) {
s, ok := c.cached(cred)
if !ok {
password, err := c.keys.Decrypt(cred.EncryptedPassword, cred.KeyVersion)
if err != nil {
return nil, err
}
if s, err = c.signIn(ctx, cred.APIEmail, password); err != nil {
return nil, err
}
c.store(cred, s)
}
points, err := c.fetchHistory(ctx, s, loc)
if !errors.Is(err, errUnauthorized) {
return points, err
}
// Token recusado: descarta a sessão e tenta uma vez com login novo.
c.Invalidate(cred.UserID)
password, err := c.keys.Decrypt(cred.EncryptedPassword, cred.KeyVersion)
if err != nil {
return nil, err
}
if s, err = c.signIn(ctx, cred.APIEmail, password); err != nil {
return nil, err
}
c.store(cred, s)
points, err = c.fetchHistory(ctx, s, loc)
if errors.Is(err, errUnauthorized) {
return nil, fmt.Errorf("%w: token recusado mesmo após novo login", ErrAuthRejected)
}
return points, err
}
// Validate testa uma credencial ainda não gravada: login + uma leitura do histórico.
func (c *Client) Validate(ctx context.Context, email, password string, loc *time.Location) error {
s, err := c.signIn(ctx, email, password)
if err != nil {
return err
}
_, err = c.fetchHistory(ctx, s, loc)
if errors.Is(err, errUnauthorized) {
return fmt.Errorf("%w: histórico recusou o token", ErrAuthRejected)
}
return err
}
// Invalidate descarta a sessão em memória (ex.: credencial alterada).
func (c *Client) Invalidate(userID int64) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.sessions, userID)
}
func (c *Client) cached(cred domain.Credential) (session, bool) {
c.mu.Lock()
defer c.mu.Unlock()
item, ok := c.sessions[cred.UserID]
if !ok || !item.credentialsUpdated.Equal(cred.UpdatedAt) {
return session{}, false
}
return item.session, true
}
func (c *Client) store(cred domain.Credential, s session) {
c.mu.Lock()
defer c.mu.Unlock()
c.sessions[cred.UserID] = cachedSession{session: s, credentialsUpdated: cred.UpdatedAt}
}
type authRequest struct {
Login string `json:"login"`
Password string `json:"password"`
}
type authResponse struct {
Token string `json:"token"`
ClientID string `json:"client_id"`
Data struct {
Login string `json:"login"`
} `json:"data"`
}
func (c *Client) signIn(ctx context.Context, email, password string) (session, error) {
body, err := json.Marshal(authRequest{Login: email, Password: password})
if err != nil {
return session{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authURL, bytes.NewReader(body))
if err != nil {
return session{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return session{}, networkError("login", err)
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden ||
resp.StatusCode == http.StatusUnprocessableEntity:
return session{}, fmt.Errorf("%w (login HTTP %d)", ErrAuthRejected, resp.StatusCode)
case resp.StatusCode < 200 || resp.StatusCode > 299:
return session{}, &statusError{op: "login", code: resp.StatusCode}
}
var payload authResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return session{}, fmt.Errorf("resposta de login ilegível: %w", err)
}
if payload.Token == "" || payload.ClientID == "" {
return session{}, errors.New("resposta de login sem token ou client_id")
}
uid := payload.Data.Login
if uid == "" {
uid = email
}
return session{client: payload.ClientID, token: payload.Token, uid: uid}, nil
}
type historyResponse struct {
TimeCards []struct {
Date string `json:"date"`
Time string `json:"time"`
Disabled bool `json:"disabled"`
} `json:"time_cards"`
}
var errUnauthorized = errors.New("token não autorizado")
func (c *Client) fetchHistory(ctx context.Context, s session, loc *time.Location) ([]domain.Point, error) {
if _, err := url.Parse(c.historyURL); err != nil {
return nil, fmt.Errorf("URL do histórico inválida: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.historyURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("access-token", s.token)
req.Header.Set("client", s.client)
req.Header.Set("uid", s.uid)
resp, err := c.http.Do(req)
if err != nil {
return nil, networkError("histórico", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, errUnauthorized
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, &statusError{op: "histórico", code: resp.StatusCode}
}
var payload historyResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("resposta de histórico ilegível: %w", err)
}
points := make([]domain.Point, 0, len(payload.TimeCards))
for _, raw := range payload.TimeCards {
if raw.Disabled {
continue
}
value := strings.TrimSpace(raw.Date) + " " + strings.TrimSpace(raw.Time)
occurredAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, loc)
if err != nil {
return nil, fmt.Errorf("%w: horário %q", domain.ErrInvalidPointHistory, value)
}
points = append(points, domain.Point{OccurredAt: occurredAt})
}
return points, nil
}
type statusError struct {
op string
code int
}
func (e *statusError) Error() string { return fmt.Sprintf("PontoMais: %s retornou HTTP %d", e.op, e.code) }
type transientError struct{ err error }
func (e *transientError) Error() string { return e.err.Error() }
func (e *transientError) Unwrap() error { return e.err }
// networkError remove a URL do erro (por segurança e legibilidade) e marca como transitório.
func networkError(op string, err error) error {
var urlErr *url.Error
if errors.As(err, &urlErr) {
err = urlErr.Err
}
return &transientError{err: fmt.Errorf("PontoMais: falha de rede no %s: %w", op, err)}
}
func isTransient(err error) bool {
var t *transientError
if errors.As(err, &t) {
return true
}
var s *statusError
return errors.As(err, &s) && (s.code >= 500 || s.code == http.StatusTooManyRequests)
}