3. O código
Um pequeno bot de notas: /note buy milk salva uma nota, /notes mostra as suas últimas cinco.
requirements.txt
python-telegram-bot>=22,<23
aiomysql
bot.py
import os
import aiomysql
from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes
async def post_init(application: Application) -> None:
# One pool for the whole bot. The values come from the Environment pane.
pool = await aiomysql.create_pool(
host=os.environ["DB_HOST"],
port=int(os.environ["DB_PORT"]),
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
db=os.environ["DB_NAME"],
autocommit=True,
maxsize=5,
)
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"CREATE TABLE IF NOT EXISTS notes ("
"id INT AUTO_INCREMENT PRIMARY KEY, "
"user_id BIGINT NOT NULL, "
"text VARCHAR(500) NOT NULL, "
"created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "
"KEY user_id (user_id))"
)
application.bot_data["pool"] = pool
print("Database ready")
print(f"Logged in as @{application.bot.username}")
async def post_shutdown(application: Application) -> None:
pool = application.bot_data.get("pool")
if pool is not None:
pool.close()
await pool.wait_closed()
async def note(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = " ".join(context.args).strip()
if not text:
await update.effective_message.reply_text("Write your note after the command, for example: /note buy milk")
return
if len(text) > 500:
await update.effective_message.reply_text("A note can be up to 500 characters.")
return
pool = context.bot_data["pool"]
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"INSERT INTO notes (user_id, text) VALUES (%s, %s)",
(update.effective_user.id, text),
)
await update.effective_message.reply_text("Saved. Send /notes to see your last five.")
async def notes(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
pool = context.bot_data["pool"]
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"SELECT text FROM notes WHERE user_id = %s ORDER BY id DESC LIMIT 5",
(update.effective_user.id,),
)
rows = await cur.fetchall()
if not rows:
await update.effective_message.reply_text("You have no notes yet.")
return
await update.effective_message.reply_text("\n".join(f"- {text}" for (text,) in rows))
def main() -> None:
application = (
Application.builder()
.token(os.environ["TELEGRAM_BOT_TOKEN"])
.post_init(post_init)
.post_shutdown(post_shutdown)
.build()
)
application.add_handler(CommandHandler("note", note))
application.add_handler(CommandHandler("notes", notes))
application.run_polling()
if __name__ == "__main__":
main()
O que ele faz: run_polling() primeiro pergunta ao Telegram quem é o bot, depois executa post_init e só então começa a buscar mensagens. post_init abre o pool, cria a tabela notes se ela não existir, coloca o pool em application.bot_data["pool"] e imprime Database ready e Logged in as @your_bot. Todo handler recebe o mesmo dicionário como context.bot_data. Quando o bot para, run_polling() executa post_shutdown, que fecha o pool.
context.args é o texto depois do comando, dividido em palavras, então o código o junta de volta com espaços simples: quebras de linha e espaços duplos dentro de uma nota viram um só espaço. Se você precisa da nota exatamente como foi digitada, use update.effective_message.text e corte o comando você mesmo.
Os marcadores %s são preenchidos pelo driver, então uma nota como '); DROP TABLE notes; -- é salva como texto simples. No nosso teste, ela foi salva como a linha 2 e a tabela continuou lá. Nunca monte o SQL com uma f-string.
Salve os IDs do Telegram como BIGINT
A documentação da Bot API do Telegram diz que um ID de usuário ou de chat “pode ter mais de 32 bits significativos”, mas tem no máximo 52, então um inteiro de 64 bits é seguro. A página sobre IDs traz as faixas: IDs de usuário vão até 1.099.511.627.775, enquanto IDs de grupos, supergrupos e canais são negativos. Supergrupos e canais vão de -1.000.000.000.001 a -1.997.852.516.352 (por exemplo -1001234567890). O INT do MySQL para em 2.147.483.647. No nosso teste, com o mesmo modo SQL estrito que o nosso servidor de banco usa, salvar o ID de usuário 7.000.000.001 em uma coluna INT falhou. O aiomysql lançou:
pymysql.err.DataError: (1264, "Out of range value for column 'user_id' at row 1")
Uma coluna BIGINT o salvou, e -1001234567890 também. Use BIGINT simples, não BIGINT UNSIGNED, para que os IDs de chat também caibam.
4. Execute
- Envie
bot.py e requirements.txt em Files.
- Pressione Iniciar e abra Console. A inicialização instala os pacotes de
requirements.txt e depois mostra [PlayNHost] Starting your bot, Database ready e Logged in as @your_bot. Essa última linha muda o status em Visão geral para Online no Telegram.
- No Telegram, envie ao seu bot
/note buy milk e depois /notes. Para ver as linhas, abra a página do banco e clique em Abrir no phpMyAdmin.
A partir da segunda inicialização, o console também mostra Warning: Table 'notes' already exists, com o caminho do cursors.py do aiomysql na frente. É o aviso do MariaDB para CREATE TABLE IF NOT EXISTS, repassado pelo driver. Não há nada de errado.
No nosso teste, o substituto do Telegram enviou estes comandos e recebeu estas respostas:
| Enviado | Resposta |
/note buy milk | Saved. Send /notes to see your last five. |
/note | Write your note after the command, for example: /note buy milk |
/note e 501 caracteres | A note can be up to 500 characters. |
/notes | As notas salvas, da mais recente para a mais antiga |
/notes de um segundo usuário | You have no notes yet. |
Uma atualização de cada vez
Com as configurações padrão, o python-telegram-bot trata uma atualização de cada vez: o próximo comando espera o handler anterior terminar. No nosso teste, 4 comandos que chegaram juntos foram respondidos na ordem em que chegaram, e depois do teste o bot mantinha 1 conexão aberta. maxsize=5 continua importando se você ativar concurrent_updates em Application.builder(), ou se jobs e handlers consultarem o banco ao mesmo tempo: o pool nunca abre mais de 5 conexões, e uma consulta extra espera por uma livre.
A armadilha do autocommit
O aiomysql abre conexões com o autocommit desligado. Um INSERT fica então dentro de uma transação aberta. Quando a conexão volta ao pool com essa transação ainda aberta, o aiomysql a fecha e o MariaDB descarta a linha não confirmada. No nosso teste, uma linha inserida em um pool sem autocommit=True foi vista por 0 outras conexões. Ou passe autocommit=True para create_pool, como acima, ou chame await conn.commit() depois das suas gravações.
No nosso teste, apagamos a tabela enquanto o bot rodava e enviamos /notes. O python-telegram-bot imprimiu No error handlers are registered, logging exception. e um traceback terminando em pymysql.err.ProgrammingError: (1146, "Table '...notes' doesn't exist"), não enviou resposta a esse comando e continuou rodando: quando a tabela voltou, o /note seguinte foi salvo. Quem enviou o comando não vê nada, então capture o erro no handler, ou registre um handler com application.add_error_handler(), e responda com uma mensagem curta.
Uma noite tranquila não quebra o pool
O nosso servidor de banco fecha uma conexão que ficou ociosa por 8 horas. Um bot que não recebe mensagens por tanto tempo, de madrugada por exemplo, encontra as conexões do pool fechadas. O aiomysql verifica isso quando um handler pede uma conexão e abre uma nova no lugar da fechada. Testamos com o limite de ociosidade reduzido para 5 segundos: depois de 7 segundos de silêncio, o /note seguinte foi salvo sem erro.
Limites que importam para o seu pool
- 20 conexões ao mesmo tempo por usuário do banco. Todo processo que usa o mesmo login conta. A conexão 21 é recusada com
(1226, "User '...' has exceeded the 'max_user_connections' resource (current value: 20)"). maxsize=5 mantém o bot bem abaixo disso.
- Uma consulta que roda por mais de 120 segundos é interrompida. O MariaDB a encerra com o erro 1969,
Query execution was interrupted (max_statement_time exceeded). Em um teste anterior nosso, em uma conta configurada do mesmo jeito, isso aconteceu depois de 120,03 segundos. As consultas de um bot normalmente levam milissegundos, então isso só atinge uma consulta descontrolada.
- 500 MB por banco de dados. A página Bancos de dados mostra quanto está em uso. Com 500 MB, as gravações são interrompidas até você apagar linhas.
- Os backups do servidor não incluem o banco de dados. Exporte-o do phpMyAdmin de vez em quando.
Erros e o que significam
Estes são os erros que o aiomysql deu no nosso teste, escritos do jeito que terminam um traceback no console (vêm do PyMySQL, que o aiomysql usa por baixo):
pymysql.err.OperationalError: (1045, "Access denied for user ..."): DB_USER ou DB_PASSWORD errado. Depois de Redefinir senha no cartão Conexão, a senha antiga para de funcionar na hora: troque DB_PASSWORD e reinicie o bot.
pymysql.err.OperationalError: (2003, "Can't connect to MySQL server on ..."): host ou porta errados, ou uma conexão de fora da PlayNHost. O banco só aceita conexões de servidores PlayNHost e do phpMyAdmin, então é isso também que você recebe no seu computador.
pymysql.err.OperationalError: (1226, ... 'max_user_connections' ...): conexões demais abertas, veja os limites acima. Por exemplo, uma segunda cópia do bot ou um pool aberto a cada mensagem.
pymysql.err.DataError: (1264, "Out of range value for column 'user_id' at row 1"): uma coluna de ID é INT. Mude-a para BIGINT.
pymysql.err.ProgrammingError: (1146, "Table '...' doesn't exist"): a tabela não existe. O código acima a cria na inicialização; se você a criou à mão, confira o nome.
Uma variável ausente em Ambiente para o bot antes de ele conectar, com KeyError: 'DB_HOST' ou o nome da que falta, porque o código lê cada valor com os.environ[...]. Adicione-a e reinicie o bot: as variáveis valem na próxima vez que ele inicia.
Um token errado para o bot antes da parte do banco, porque post_init só roda depois que o Telegram aceita o token. Com o nosso token falso contra o Telegram de verdade, o bot nunca imprimiu Database ready e parou depois de cerca de 1 segundo com telegram.error.InvalidToken: The token `...` was rejected by the server. Essa mensagem contém o token inteiro, então não cole em nenhum lugar público uma saída do console que o contenha. Copie o token de novo do @BotFather e cole na página Bot, painel Telegram. Mais erros do Telegram: Como corrigir o erro 409 Conflict do bot do Telegram.
Quanta memória ele usa
No nosso teste, o bot em execução usou 48,5 MB de memória (residente) depois de responder aos 8 comandos, com o pool aberto, em um contêiner limitado a 512 MB como o nosso menor plano de bot. No nosso teste de hospedagem anterior, o python-telegram-bot sozinho mediu 44 MB (esse bot parou em um token recusado, antes de tratar qualquer mensagem), então a diferença, cerca de 4,5 MB, cobre o aiomysql, o pool e os comandos tratados. Instalar python-telegram-bot e aiomysql com pip do zero chegou a um pico de 73 MB, incluindo o cache de arquivos. As bibliotecas lado a lado: python-telegram-bot vs aiogram na PlayNHost.
Precisa de um servidor para o seu bot do Telegram com banco de dados incluído? Veja os planos de bot do Telegram.