O HealthJournal é um bot pessoal de registro de saúde: mando a foto da refeição, mando a leitura de pressão arterial, ele guarda. A lista de dependências é curta — python-telegram-bot==22.7 (requirements.txt:35), o sqlite3 da biblioteca padrão, nenhum LLM em lugar nenhum. Escrever isso foi trabalho de uma tarde. Manter o bot vivo por meses num PC Windows doméstico, sem ninguém olhando, levou três correções, e cada uma veio depois de uma falha que não apareceu como erro em canto algum.
A parte difícil nunca foi escrever o bot
Um bot de long polling sobe fácil. Você registra os handlers, chama run_polling(), ele responde. O problema aparece na semana três, quando você para de olhar: a máquina reinicia por atualização do Windows, a internet cai por dois minutos, o processo morre num horário em que você está dormindo. Não existe time de plantão nem servidor Linux dedicado. Existe um PC na mesa e eu, que não estou olhando.
Pior que o processo morrer é ele não morrer. Falha silenciosa é quando o bot continua listado no gerenciador de tarefas, o polling continua buscando mensagens, e mesmo assim uma parte do sistema já parou. Você descobre quando abre o app para registrar um almoço e percebe que o de ontem não está lá, possivelmente semanas depois do começo do estrago.
A primeira tentativa foi a que qualquer um faria: Docker. Havia um Dockerfile e um docker-compose.yml com restart: always, que resolve muito bem o caso de "o processo caiu". Só que "o processo caiu" era o menos grave dos problemas que eu tinha, e o Docker no Windows trouxe um pior.
Hoje é o Task Scheduler chamando o pythonw.exe direto
O desenho atual não tem container. O Windows Task Scheduler chama o pythonw.exe direto, sem .cmd intermediário, e o ponto de entrada de fato é _run_forever() (bot.py:233-253, invocado em bot.py:256). Essa função chama main() (bot.py:210-230) dentro de um laço; main() monta o Application do python-telegram-bot e chama app.run_polling(allowed_updates=Update.ALL_TYPES) (bot.py:228).
A persistência continua sendo o sqlite3 da biblioteca padrão, sem SQLAlchemy e sem servidor de banco. Para um bot que grava algumas linhas por dia e é lido por uma pessoa só, qualquer camada acima disso é peso morto. A troca é que a integridade do arquivo passa a ser problema seu, e ela cobrou caro.
Como o pythonw.exe roda sem console, não existe stdout para onde mandar log. Por isso _setup_file_logging() (bot.py:175-190) configura um RotatingFileHandler com 5 MB por arquivo e 3 backups. Sem isso o bot roda cego: quando algo quebra às três da manhã, não sobra nada para ler de manhã.
Três falhas diferentes, e nenhuma delas apareceu como erro
A primeira apagou dados. Com o bot em Docker, o SQLite em modo WAL gravando através de um bind mount do Windows corrompeu a persistência sem levantar exceção nenhuma: cerca de 75% dos registros se perderam depois de 25 dias de uso. O bot respondia, confirmava cada registro, e o arquivo do outro lado não tinha o que ele dizia ter. A correção, em 8 de maio de 2026, foi tirar o Docker da frente e rodar direto na máquina, pelo Task Scheduler.
LEIA TAMBÉM · ExplicaçãoNo Windows, um SQLite WAL trava quando um processo morre à forçaWAL deixa vários programas usarem o mesmo banco SQLite ao mesmo tempo, mas só funciona se todos desligarem direito. No Windows, um morto à força pode travar os outros — a saída é dar um arquivo separado pra cada um.A segunda matou o supervisor. O laço de reinício rodava dentro de um cmd.exe, e fechar a janela do console derrubava o processo com STATUS_CONTROL_C_EXIT: sem crash aparente, sem log, sem reinício. A janela parecia um detalhe de conveniência e era a coisa que segurava o bot no ar. Em 13 de maio, virou pythonw.exe lançado direto pelo Task Scheduler, com o supervisor vivendo dentro do próprio processo Python.
A terceira foi a que me convenceu de que reinício programado não é preguiça de depurar. Os pools de conexão httpx que o python-telegram-bot mantém abertos apodrecem ao longo de dias. O efeito é o pior possível: o long polling continua funcionando, então o bot passa em qualquer verificação superficial, enquanto os envios começam a falhar em silêncio. A correção, em 14 de maio, foi reconstruir o Application inteiro a cada RESTART_INTERVAL_HOURS = 12 (bot.py:14).
O supervisor trata a falha que grita e a que não grita em lugares separados
RESTART_INTERVAL_HOURS = 12
class _RestartRequested(Exception):
pass
async def _periodic_restart_task(app):
await asyncio.sleep(RESTART_INTERVAL_HOURS * 3600)
app.create_task(app.stop())
async def _post_init(app):
app.create_task(_periodic_restart_task(app))
def main():
app = (
Application.builder()
.token(TELEGRAM_BOT_TOKEN)
.post_init(_post_init)
.build()
)
# ... add handlers ...
app.run_polling(allowed_updates=Update.ALL_TYPES)
raise _RestartRequested(f"scheduled rotation after {RESTART_INTERVAL_HOURS}h")
def _run_forever():
_setup_file_logging()
while True:
try:
main()
return
except _RestartRequested as e:
logger.info("Restart requested (%s) — re-entering main() in 2s", e)
time.sleep(2)
except SystemExit:
raise
except KeyboardInterrupt:
return
except Exception:
logger.exception("Bot crashed — restarting in 10s")
time.sleep(10)
if __name__ == "__main__":
_run_forever()
O que faz esse desenho funcionar é a separação. O run_polling() não levanta exceção quando só os envios falham, que é exatamente a terceira falha. Nenhum except do mundo captura um erro que nunca é levantado. Por isso o reinício programado tem caminho próprio: _periodic_restart_task dorme doze horas, chama app.stop(), o run_polling() retorna e main() levanta _RestartRequested para avisar o laço de fora que aquilo foi rotação, não acidente.
O except Exception genérico cobre a outra metade: quebra de verdade, com traceback no arquivo de log, dez segundos de espera e nova tentativa. O except SystemExit: raise está ali de propósito. Um sys.exit() disparado lá dentro precisa passar reto pelo supervisor, senão o bot ressuscita a si mesmo justamente quando alguém pediu para ele parar.
Um trecho cobre a falha que grita. O outro cobre a falha que não grita. Foi a única forma que encontrei de ter as duas ao mesmo tempo.
Não existe conta de nuvem, e mesmo assim tem custo
A hospedagem é zero. O bot roda numa máquina que já fica ligada de qualquer jeito, agendado pelo Task Scheduler, sem VPS e sem serviço pago no meio.
O que custa é atenção. O log precisa de teto, e por isso os 5 MB por arquivo e os 3 backups do RotatingFileHandler: um bot que loga bastante enche disco sozinho ao longo de meses, e ninguém percebe até o disco acabar. O reinício de doze horas também tira o bot do ar por alguns segundos, duas vezes por dia. Para um registro pessoal de saúde isso é invisível; para um bot que atende cliente, essa janela entra na conta antes de você copiar o padrão.
Da próxima vez eu tento corromper o banco antes de confiar nele
Duas suposições custaram caro. A primeira foi tratar Docker com bind mount como ambiente neutro para o SQLite no Windows, sem testar corrupção sob uso real. Teria bastado rodar o bot por uma semana gravando de verdade e comparar a contagem de registros com o que eu sabia ter mandado. Vinte e cinco dias e 75% dos dados depois, o teste saiu bem mais caro do que seria.
A segunda foi confiar no reinício sem testar o reinício. Fechar a janela do console, esperar, conferir se o processo voltou: é um teste que ninguém faz porque parece bobo demais para valer o tempo. Era exatamente o que faltava.
A verificação que eu não tinha é a mais simples de todas. Um bot que está rodando e um bot que está funcionando não são a mesma pergunta. Polling ativo prova que o processo existe, e só. Desde maio o que eu confiro é o dado do outro lado, não o processo.
