// 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 — aprovar cadastro (exige credencial validada)\n") b.WriteString("/desativarusuario — 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 (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 (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) }