361 lines
11 KiB
Go
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)
|
|
}
|