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ã.

LEIA TAMBÉM · TutorialEscrever um bot de Telegram em Python, do token no BotFather até rodando em DockerUm bot de Telegram é um programa que responde sozinho no seu Telegram. Aqui ele roda em 'long polling', perguntando por mensagens novas, e mora num Docker que liga ele de novo se o servidor cair ou reiniciar.

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.