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

361 lines
11 KiB
Go

// Package bot é o adapter de entrada do Telegram: traduz comandos em casos de
// uso e formata as respostas (ARQUITETURA.md, 8.4).
package bot
import (
"context"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"botpontos/internal/adapters/postgres"
"botpontos/internal/app"
"botpontos/internal/domain"
"botpontos/internal/rules"
)
type Router struct {
svc *app.Service
log *slog.Logger
}
func NewRouter(svc *app.Service, log *slog.Logger) *Router {
return &Router{svc: svc, log: log}
}
// Handle processa uma mensagem e devolve o texto da resposta.
func (r *Router) Handle(ctx context.Context, chatID int64, name, text string) string {
command, args := parse(text)
if command == "" {
return "Envie /ajuda para ver os comandos disponíveis."
}
user, err := r.svc.UserByChatID(ctx, chatID)
known := err == nil
if err != nil && !errors.Is(err, domain.ErrNotFound) {
r.log.Error("falha ao identificar usuário", "error", err)
return msgInternalError
}
switch command {
case "/start", "/iniciar":
if !known {
return "Olá! Este é o Bot de Pontos. Envie /cadastrar para solicitar acesso."
}
return helpText(user)
case "/cadastrar":
return r.register(ctx, chatID, name, known, user)
}
if !known {
return "Você ainda não tem cadastro. Envie /cadastrar para solicitar acesso."
}
switch command {
case "/ajuda":
return helpText(user)
case "/status":
return r.status(ctx, user)
}
if !user.IsActive() {
if user.Status == domain.UserPending {
return "Seu cadastro ainda aguarda aprovação de um administrador."
}
return "Seu acesso está desativado. Procure um administrador."
}
switch command {
case "/entrada2":
return r.today(ctx, user, domain.ResumeEntrada2)
case "/saida2":
return r.today(ctx, user, domain.ResumeSaida2)
case "/ativarponto":
return r.enable(ctx, user)
case "/desativarponto":
return r.disable(ctx, user)
}
if !user.IsActiveBotAdmin() {
return "Comando desconhecido. Envie /ajuda para ver os comandos disponíveis."
}
switch command {
case "/usuarios":
return r.listUsers(ctx)
case "/ativarusuario":
return r.activateUser(ctx, user, args)
case "/desativarusuario":
return r.disableUser(ctx, user, args)
}
return "Comando desconhecido. Envie /ajuda para ver os comandos disponíveis."
}
const msgInternalError = "Ocorreu um erro ao processar o comando. Tente novamente em instantes."
// parse normaliza "/status@nome_do_bot arg" para ("/status", ["arg"]).
func parse(text string) (string, []string) {
fields := strings.Fields(strings.TrimSpace(text))
if len(fields) == 0 || !strings.HasPrefix(fields[0], "/") {
return "", nil
}
command := strings.ToLower(fields[0])
if at := strings.Index(command, "@"); at >= 0 {
command = command[:at]
}
return command, fields[1:]
}
func helpText(u domain.User) string {
var b strings.Builder
b.WriteString("Comandos:\n")
b.WriteString("/status — situação do cadastro e dos lembretes\n")
if u.IsActive() {
b.WriteString("/entrada2 — retorno previsto do almoço\n")
b.WriteString("/saida2 — saída prevista\n")
b.WriteString("/ativarponto — ligar os lembretes automáticos\n")
b.WriteString("/desativarponto — pausar os lembretes\n")
}
b.WriteString("/ajuda — esta lista\n")
if u.IsActiveBotAdmin() {
b.WriteString("\nAdministração:\n")
b.WriteString("/usuarios — lista de usuários\n")
b.WriteString("/ativarusuario <id> — aprovar cadastro (exige credencial validada)\n")
b.WriteString("/desativarusuario <id> — desativar usuário\n")
}
b.WriteString("\nO bot não registra ponto; a fonte oficial continua sendo a PontoMais.")
return b.String()
}
func (r *Router) register(ctx context.Context, chatID int64, name string, known bool, user domain.User) string {
if known {
switch user.Status {
case domain.UserPending:
return "Seu pedido de cadastro já foi enviado e aguarda aprovação."
case domain.UserActive:
return "Você já está cadastrado. Envie /ajuda para ver os comandos."
default:
return "Seu acesso está desativado. Procure um administrador."
}
}
if _, _, err := r.svc.Register(ctx, chatID, name); err != nil {
r.log.Error("falha no cadastro", "error", err)
return msgInternalError
}
return "Pedido de cadastro enviado. Um administrador vai configurar sua credencial da PontoMais e aprovar o acesso."
}
func (r *Router) status(ctx context.Context, user domain.User) string {
info, err := r.svc.Status(ctx, user.ID)
if err != nil {
r.log.Error("falha no /status", "user_id", user.ID, "error", err)
return msgInternalError
}
var b strings.Builder
fmt.Fprintf(&b, "Cadastro: %s\n", statusLabel(info.User.Status))
switch {
case !info.HasCredential:
b.WriteString("Credencial PontoMais: não configurada\n")
case !info.Validated:
b.WriteString("Credencial PontoMais: configurada, ainda não validada\n")
case info.Failures > 0:
fmt.Fprintf(&b, "Credencial PontoMais: validada (%d falha(s) seguida(s) na última consulta)\n", info.Failures)
default:
b.WriteString("Credencial PontoMais: validada\n")
}
if info.User.NotificationsEnabled {
b.WriteString("Lembretes: ligados\n")
} else {
fmt.Fprintf(&b, "Lembretes: pausados%s\n", pauseLabel(info.User.PausedReason))
}
origin := "global"
if info.CustomWindow {
origin = "personalizada"
}
fmt.Fprintf(&b, "Janela de consulta: %s às %s (%s)\n", info.Window.Start, info.Window.End, origin)
if !info.BusinessDay {
b.WriteString("Hoje: não é dia útil (sem consultas)")
} else {
b.WriteString("Hoje: " + dayLabel(info.Today))
}
return b.String()
}
func statusLabel(s domain.UserStatus) string {
switch s {
case domain.UserActive:
return "ativo"
case domain.UserPending:
return "aguardando aprovação"
default:
return "desativado"
}
}
func pauseLabel(r *domain.PauseReason) string {
if r == nil {
return ""
}
switch *r {
case domain.PauseManual:
return " (por você, com /desativarponto)"
case domain.PauseAPIFailures:
return " (3 falhas seguidas na PontoMais; envie /ativarponto depois de conferir a credencial)"
case domain.PauseAdmin:
return " (por um administrador)"
case domain.PauseUserDisabled:
return " (acesso desativado)"
}
return ""
}
func dayLabel(d domain.UserDay) string {
switch d.Status {
case domain.DayNoPoints:
return "nenhum ponto encontrado até a confirmação; reverificando de hora em hora"
case domain.DayNotWorking:
return "nenhum ponto encontrado, consultas encerradas (envie /ativarponto se estiver trabalhando)"
case domain.DayFinished:
return "jornada encerrada"
case domain.DayWorking:
return fmt.Sprintf("acompanhando (%d ponto(s) até a última consulta)", d.LastPointCount)
default:
return "aguardando a primeira consulta"
}
}
func (r *Router) today(ctx context.Context, user domain.User, source domain.ResumeSource) string {
view, err := r.svc.Today(ctx, app.TelegramActor(user.ID), user.ID, &source)
switch {
case errors.Is(err, domain.ErrCredentialsRequired):
return "Sua credencial da PontoMais ainda não foi configurada. Procure um administrador."
case err != nil:
r.log.Warn("falha na consulta manual", "user_id", user.ID, "error", err)
return "Não consegui consultar a PontoMais agora. Tente novamente em alguns minutos."
}
loc := user.Location()
var b strings.Builder
if view.Invalid {
return "Os pontos de hoje estão repetidos ou fora de ordem. Confira no sistema oficial."
}
if view.Overtime {
return "Há mais de quatro pontos hoje (hora extra), situação fora do controle do bot."
}
if source == domain.ResumeEntrada2 {
if view.Entrada2At == nil {
return fmt.Sprintf("Encontrei %d ponto(s) hoje. O retorno do almoço é calculado a partir do segundo ponto.", len(view.Points))
}
fmt.Fprintf(&b, "Retorno previsto do almoço: %s (almoço de %s).",
rules.FormatClock(*view.Entrada2At, loc), rules.FormatMinutesShort(view.LunchMinutes))
} else {
switch {
case view.Saida2Error != "":
return "Não foi possível calcular a saída: o tempo trabalhado antes do almoço já completa a jornada."
case view.Saida2At == nil:
return fmt.Sprintf("Encontrei %d ponto(s) hoje. A saída é calculada a partir do terceiro ponto.", len(view.Points))
}
fmt.Fprintf(&b, "Saída prevista: %s. Para completar %s.",
rules.FormatClock(*view.Saida2At, loc), rules.FormatMinutes(view.DailyWorkMinutes))
}
if view.Resumed {
b.WriteString("\nAs consultas automáticas de hoje foram retomadas.")
}
return b.String()
}
func (r *Router) enable(ctx context.Context, user domain.User) string {
_, err := r.svc.EnableNotifications(ctx, app.TelegramActor(user.ID), user.ID)
switch {
case errors.Is(err, domain.ErrCredentialsRequired):
return "Sua credencial da PontoMais ainda não foi validada. Procure um administrador."
case err != nil:
r.log.Error("falha no /ativarponto", "user_id", user.ID, "error", err)
return msgInternalError
}
return "Lembretes ligados. Você receberá o aviso de retorno do almoço e de saída nos dias úteis."
}
func (r *Router) disable(ctx context.Context, user domain.User) string {
if err := r.svc.DisableNotifications(ctx, app.TelegramActor(user.ID), user.ID, domain.PauseManual); err != nil {
r.log.Error("falha no /desativarponto", "user_id", user.ID, "error", err)
return msgInternalError
}
return "Lembretes pausados. As consultas manuais (/entrada2, /saida2) continuam disponíveis."
}
func (r *Router) listUsers(ctx context.Context) string {
users, err := r.svc.Store().ListUsers(ctx, postgres.UserFilter{})
if err != nil {
r.log.Error("falha no /usuarios", "error", err)
return msgInternalError
}
if len(users) == 0 {
return "Nenhum usuário cadastrado."
}
var b strings.Builder
for _, s := range users {
credential := "sem credencial"
if s.CredentialValidated {
credential = "credencial ok"
} else if s.HasCredential {
credential = "credencial não validada"
}
reminders := "lembretes off"
if s.User.NotificationsEnabled {
reminders = "lembretes on"
}
fmt.Fprintf(&b, "%d — %s — %s, %s, %s\n", s.User.ID, s.User.DisplayName, statusLabel(s.User.Status), credential, reminders)
}
return strings.TrimSpace(b.String())
}
func parseID(args []string) (int64, bool) {
if len(args) != 1 {
return 0, false
}
id, err := strconv.ParseInt(args[0], 10, 64)
return id, err == nil && id > 0
}
func (r *Router) activateUser(ctx context.Context, admin domain.User, args []string) string {
id, ok := parseID(args)
if !ok {
return "Uso: /ativarusuario <id> (veja os IDs em /usuarios)"
}
u, err := r.svc.ActivateUser(ctx, app.TelegramActor(admin.ID), id)
switch {
case errors.Is(err, domain.ErrNotFound):
return "Usuário não encontrado."
case errors.Is(err, domain.ErrCredentialsRequired):
return "Esse usuário ainda não tem credencial da PontoMais validada. Configure-a pelo painel antes de ativar."
case err != nil:
r.log.Error("falha no /ativarusuario", "error", err)
return msgInternalError
}
return fmt.Sprintf("%s (ID %d) está ativo.", u.DisplayName, u.ID)
}
func (r *Router) disableUser(ctx context.Context, admin domain.User, args []string) string {
id, ok := parseID(args)
if !ok {
return "Uso: /desativarusuario <id> (veja os IDs em /usuarios)"
}
err := r.svc.DisableUser(ctx, app.TelegramActor(admin.ID), id)
switch {
case errors.Is(err, domain.ErrNotFound):
return "Usuário não encontrado."
case err != nil:
r.log.Error("falha no /desativarusuario", "error", err)
return msgInternalError
}
return fmt.Sprintf("Usuário %d desativado. Os lembretes agendados foram cancelados.", id)
}