Projeto bot pontos
This commit is contained in:
360
backend/internal/bot/router.go
Normal file
360
backend/internal/bot/router.go
Normal file
@@ -0,0 +1,360 @@
|
||||
// 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)
|
||||
}
|
||||
40
backend/internal/bot/router_test.go
Normal file
40
backend/internal/bot/router_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
command string
|
||||
args []string
|
||||
}{
|
||||
{"/status", "/status", []string{}},
|
||||
{"/Status@meu_bot", "/status", []string{}},
|
||||
{" /ativarusuario 42 ", "/ativarusuario", []string{"42"}},
|
||||
{"oi", "", nil},
|
||||
{"", "", nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
command, args := parse(c.in)
|
||||
if command != c.command {
|
||||
t.Errorf("parse(%q) comando = %q, esperado %q", c.in, command, c.command)
|
||||
}
|
||||
if c.args != nil && !reflect.DeepEqual(args, c.args) {
|
||||
t.Errorf("parse(%q) args = %v, esperado %v", c.in, args, c.args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseID(t *testing.T) {
|
||||
if id, ok := parseID([]string{"42"}); !ok || id != 42 {
|
||||
t.Errorf("parseID(42) = %d, %v", id, ok)
|
||||
}
|
||||
for _, invalid := range [][]string{nil, {"abc"}, {"-1"}, {"1", "2"}} {
|
||||
if _, ok := parseID(invalid); ok {
|
||||
t.Errorf("parseID(%v) deveria falhar", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
76
backend/internal/bot/runner.go
Normal file
76
backend/internal/bot/runner.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/adapters/telegram"
|
||||
)
|
||||
|
||||
// Runner faz o long polling do Telegram e responde cada mensagem.
|
||||
type Runner struct {
|
||||
client *telegram.Client
|
||||
router *Router
|
||||
log *slog.Logger
|
||||
pollTimeout int
|
||||
}
|
||||
|
||||
func NewRunner(client *telegram.Client, router *Router, log *slog.Logger, pollTimeout int) *Runner {
|
||||
return &Runner{client: client, router: router, log: log, pollTimeout: pollTimeout}
|
||||
}
|
||||
|
||||
// Run roda até o contexto ser cancelado. Erros de rede não derrubam o
|
||||
// worker: o loop espera alguns segundos e tenta de novo.
|
||||
func (r *Runner) Run(ctx context.Context) {
|
||||
var offset int64
|
||||
backoff := time.Second
|
||||
r.log.Info("bot do Telegram iniciado")
|
||||
for ctx.Err() == nil {
|
||||
updates, err := r.client.GetUpdates(ctx, offset, r.pollTimeout)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
r.log.Warn("falha no getUpdates do Telegram", "error", err, "retry_in", backoff.String())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
if backoff < 30*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
|
||||
for _, update := range updates {
|
||||
offset = update.UpdateID + 1
|
||||
msg := update.Message
|
||||
// Só conversas privadas: o bot trata dados pessoais de jornada.
|
||||
if msg == nil || msg.Text == "" || (msg.Chat.Type != "" && msg.Chat.Type != "private") {
|
||||
continue
|
||||
}
|
||||
r.handle(ctx, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) handle(ctx context.Context, msg *telegram.Message) {
|
||||
defer func() {
|
||||
if v := recover(); v != nil {
|
||||
r.log.Error("panic ao tratar mensagem do Telegram", "panic", v)
|
||||
}
|
||||
}()
|
||||
handleCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
reply := r.router.Handle(handleCtx, msg.Chat.ID, msg.From.Name(), msg.Text)
|
||||
if reply == "" {
|
||||
return
|
||||
}
|
||||
if _, err := r.client.Send(ctx, msg.Chat.ID, reply); err != nil {
|
||||
r.log.Warn("falha ao responder no Telegram", "error", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user