Por dentro do FocoBot: arquitetura de um Pomodoro assíncrono para Discord
Como um cronômetro comunitário evoluiu para um bot em Python com máquina de estados, ciclos assíncronos, áudio, comandos híbridos e sessões isoladas por servidor.
O FocoBot começou em 2021 com uma função bastante concreta: manter no Discord o ritmo de foco que uma comunidade já praticava em lives. O bot deveria iniciar 25 minutos de trabalho, sinalizar uma pausa curta de 5 minutos e, depois de alguns ciclos, oferecer uma pausa maior.
A regra parece simples quando escrita em uma linha. Em software, ela abre uma sequência de perguntas: quem controla o cronômetro? O que acontece quando alguém envia o mesmo comando duas vezes? Como pausar sem perder o tempo decorrido? Dois servidores podem usar o bot ao mesmo tempo? Como tocar um alerta sem bloquear a contagem?
Este artigo desmonta a implementação atual do projeto e mostra como essas perguntas foram transformadas em componentes. A história que motivou o bot está em um texto separado; aqui, o foco é a engenharia.
1. O domínio antes do Discord
No centro do projeto existe um Timer que não sabe o que é canal, mensagem, usuário ou áudio. Ele conhece apenas a duração máxima, a quantidade de segundos transcorridos e um estado.
class TimerStatus(Enum):
INITIALIZED = 1
RUNNING = 2
STOPPED = 3
EXPIRED = 4
PAUSED = 5
Essa separação é pequena, mas importante. O cronômetro pode ser testado sem conexão com o Discord e sem esperar 25 minutos reais. Cada chamada a tick() representa a passagem de um segundo; quando ticks alcança max_ticks, o estado muda de RUNNING para EXPIRED.
As transições permitidas formam uma máquina de estados enxuta:
INITIALIZED ── start() ──→ RUNNING ── tick() ──→ EXPIRED
│ ↑
pause() resume()
↓ │
PAUSED
RUNNING ou PAUSED ── stop() ──→ STOPPED
RUNNING ou PAUSED ── expire() ──→ EXPIRED
pause() só funciona durante uma execução; resume() só funciona quando o timer está pausado; e tick() não altera o contador fora do estado RUNNING. O comando de pular uma etapa reaproveita expire(), em vez de criar uma segunda forma de encerrar o tempo.
Também existem duas visões do mesmo contador: tempo decorrido e tempo restante. Ambas são formatadas por uma função pura baseada em divmod, sem depender de data, fuso horário ou relógio do sistema.
@staticmethod
def format_ticks(ticks: int) -> str:
minutes, seconds = divmod(ticks, 60)
return f"{minutes:02d}:{seconds:02d}"
Essa escolha torna o comportamento previsível, embora traga uma limitação que veremos adiante.
2. Configuração como dado validado
As regras do Pomodoro ficam em uma dataclass imutável:
@dataclass(frozen=True, slots=True)
class PomodoroConfig:
focus_minutes: int = 25
short_break_minutes: int = 5
long_break_minutes: int = 10
rounds_before_long_break: int = 4
Além de documentar os valores padrão, a classe valida limites: foco entre 1 e 180 minutos, pausas entre 1 e 60 e pausa longa a cada 1 a 12 ciclos. O restante do código não precisa repetir essas verificações.
No Discord, o comando /configurar 50 10 20 3, por exemplo, produz uma nova configuração apenas se o usuário tiver a permissão Gerenciar Servidor. Se um ciclo estiver em andamento, os valores entram em vigor na próxima etapa. Isso evita alterar silenciosamente a duração de um timer já iniciado.
A configuração é mantida em memória. Portanto, ela dura enquanto o processo do bot estiver ativo, mas volta ao padrão após uma reinicialização. Persistência em arquivo ou banco de dados seria uma evolução possível; para o escopo atual, a ausência dessa camada mantém a instalação simples e explicita que o bot não administra um histórico permanente dos usuários.
3. Uma sessão para cada servidor
A primeira versão do FocoBot tinha um único timer e um único contador de rodadas dentro do Cog. Isso funcionava para o servidor da comunidade, mas criava uma restrição estrutural: qualquer outro servidor compartilharia o mesmo estado.
Na implementação atual, o estado mutável foi reunido em PomodoroSession:
@dataclass(slots=True)
class PomodoroSession:
timer: Timer = field(default_factory=Timer)
round: int = 0
phase: str = "aguardando"
timer_message: discord.Message | None = None
timer_color: int = 0
starting: bool = False
O DiscordCog mantém dois mapas indexados pelo identificador do servidor:
self.sessions: dict[int, PomodoroSession] = {}
self.configurations: dict[int, PomodoroConfig] = {}
O isolamento por guild_id permite que servidores diferentes executem ciclos simultâneos, cada um com seu timer, sua rodada e seus tempos. Dentro de um mesmo servidor continua existindo uma única sessão, o que combina com outra regra do Discord: o bot só pode manter uma conexão de voz por servidor.
O campo starting fecha uma janela de concorrência. Conectar a um canal de voz envolve operações assíncronas; sem essa trava, dois comandos enviados quase juntos poderiam passar pela checagem de “timer ativo” antes que o primeiro ciclo começasse. Ao marcar a sessão como em inicialização, o segundo comando é rejeitado enquanto a conexão está sendo criada.
4. O ciclo assíncrono
O comando comecar verifica se foi usado em um servidor, recupera a sessão correta e exige que a pessoa esteja em um canal de voz. Depois, conecta ou move o bot para esse canal e entrega o controle a run_cycle().
O fluxo pode ser resumido assim:
!comecar ou /comecar
↓
validar servidor, sessão e canal de voz
↓
conectar ao canal
↓
iniciar foco + tocar alerta
↓
esperar expiração, parada ou comando de controle
↓
pausa curta ── ou ── pausa longa a cada quatro ciclos
↓
registrar rodada e reiniciar o fluxo
Cada etapa chama run_stage(). O método define a fase, inicia o timer, toca o áudio correspondente e envia uma mensagem incorporada. Em seguida, aguarda um segundo com asyncio.sleep(1) e avança o contador.
O await é o detalhe decisivo: enquanto uma sessão espera o próximo segundo, o loop de eventos pode processar comandos, editar mensagens e atender outros servidores. Não há uma thread dedicada para cada cronômetro.
O bot suporta comandos para pausar, retomar, pular, parar e encerrar. “Parar” preserva a contagem de rodadas; “encerrar” também zera essa contagem. Essa diferença traduz uma distinção da rotina real: interromper uma sessão por alguns minutos não é o mesmo que encerrar o período de estudo.
5. Feedback sem poluir o chat
Um timer compartilhado precisa ser observável. Enviar uma nova mensagem a cada segundo seria ilegível e aumentaria desnecessariamente o volume de chamadas à API do Discord. O FocoBot envia um único embed e o edita a cada cinco segundos.
◐ Foco
████░░░░░░░░
Restante: 15:00
Decorrido: 10:00
Status: Rodando
Round 2 • atualização a cada 5 segundos
A barra possui 12 posições e é calculada pela razão entre tempo decorrido e duração total. Quatro caracteres — ◐, ◓, ◑ e ◒ — criam um indicador discreto de movimento. Estados especiais substituem a animação por símbolos estáticos de pausa, parada ou conclusão.
Quando a edição falha por uma exceção HTTP, o erro é registrado e a referência à mensagem é descartada. O cronômetro continua. Essa degradação é deliberada: perder a apresentação visual não deve interromper a etapa em execução.
Os alertas sonoros usam FFmpegPCMAudio e PCMVolumeTransformer. Há arquivos distintos para início do foco, pausa curta e pausa longa, reproduzidos no canal de voz com volume reduzido. O FFmpeg fica fora do pacote Python e precisa estar disponível no PATH da máquina que hospeda o bot.
6. Comandos antigos e novos na mesma interface
O FocoBot nasceu com comandos prefixados por !, como !começar, !parar e !tempo. A versão atual usa hybrid_command do discord.py: a mesma função pode ser chamada pelo prefixo tradicional ou como comando de barra.
O comando canônico é /comecar, sem acento, mas os aliases !começar, !comecar e !foco preservam o vocabulário usado pela comunidade. Essa compatibilidade exigiu habilitar o Message Content Intent, pois o bot ainda precisa ler o conteúdo das mensagens para reconhecer o prefixo.
No carregamento, setup_hook() registra o Cog de forma assíncrona e sincroniza a árvore de comandos com o Discord. A migração acompanha a API 2.x do discord.py sem obrigar quem já conhecia o bot a reaprender sua interface.
7. O que os testes protegem
A suíte separa três níveis de comportamento.
No domínio, os testes verificam inicialização, contagem, expiração, formatação, pausa, retomada, parada e rejeição de duração negativa. Na configuração, confirmam os valores clássicos e os limites aceitos. Na integração com Discord, usam objetos simulados para validar:
- registro dos comandos prefixados e de barra;
- presença dos arquivos de áudio;
- cálculo e apresentação da barra de progresso;
- edição da mensagem existente;
- isolamento de configurações e sessões por servidor;
- execução concorrente de etapas com
asyncio.gather; - rejeição de uma segunda inicialização;
- controle de permissão para alterar os tempos.
Os testes não precisam entrar em um servidor real nem esperar o relógio correr. O asyncio.sleep é substituído por uma função que apenas devolve o controle ao loop de eventos, e as mensagens são representadas por mocks assíncronos. Isso mantém a suíte rápida e concentra cada teste em uma regra observável.
8. Limitações técnicas
A arquitetura atual é adequada a um bot comunitário pequeno, mas não tenta esconder seus limites.
O tempo é contado pelo número de retornos de asyncio.sleep(1). Sob carga, cada retorno pode ocorrer um pouco depois de um segundo; ao longo de uma sessão, esse atraso pode acumular. Uma versão mais rigorosa deveria guardar um instante monotônico de início e calcular o restante pela diferença entre relógios, usando os ticks apenas para atualizar a interface.
Sessões e configurações vivem no processo. Uma reinicialização perde rodadas, timers ativos e personalizações. Persistir esses dados exigiria definir como restaurar uma etapa interrompida e qual relógio usar durante o período offline — uma decisão de produto, não apenas a inclusão de um banco.
A mensagem é atualizada a cada cinco segundos por sessão. Essa frequência é confortável para poucos servidores, mas precisaria ser revista em uma instalação ampla por causa dos limites da API. Também não existe coordenação distribuída: executar duas instâncias do bot criaria dois conjuntos independentes de estado.
Por fim, os testes cobrem a lógica e a integração simulada, mas não substituem um teste real de permissões, conexão de voz, FFmpeg e sincronização de comandos no Discord.
9. Executando o projeto
O projeto requer Python 3.10 ou mais recente, FFmpeg e uma aplicação criada no Discord Developer Portal. Depois de configurar o token em .env, o fluxo local é curto:
python -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install -r requirements.txt
python -m FocoBot
O token deve ser informado somente como variável BOT_TOKEN; ele não pertence ao código nem ao histórico do Git. No portal do Discord, o bot precisa de permissões para mensagens e voz, do Message Content Intent para os comandos com ! e do escopo applications.commands para os comandos de barra.
10. Conclusão
O aspecto mais interessante do FocoBot não é a contagem de 25 minutos. É a tradução de um ritual coletivo para um pequeno sistema concorrente: uma máquina de estados controla o tempo, uma configuração valida as regras, uma sessão isola cada servidor e um Cog conecta tudo à interface do Discord.
O projeto também registra uma evolução comum em software. A primeira versão resolveu uma necessidade local com um único timer; os problemas encontrados durante o uso revelaram onde criar fronteiras, como preservar compatibilidade e o que merecia testes. A arquitetura não nasceu pronta — ela foi sendo extraída do comportamento que a comunidade precisava manter.
Essa origem ajuda a explicar por que o bot continua simples. Ele não tenta transformar foco em uma plataforma completa. Faz uma coisa específica: sustenta o ritmo de pessoas que decidiram estudar juntas, mesmo quando não havia uma live conduzindo o relógio.