🤖 @BotFather — token em 60 segundos
Telegram é o único canal de chat onboardável em menos de 1 minuto sem aprovação de plataforma, sem business verification, sem revisão de app store. Você fala com um bot oficial chamado @BotFather, ele te devolve um token, e qualquer humano com celular pode mandar mensagem pro seu agente. Não tem rival nesse quesito — WhatsApp Business API leva semanas, Slack precisa de workspace, Discord precisa de servidor.
📌 O ritual de 60 segundos
- Abra Telegram, busque
@BotFather(com ícone azul de verificado). - Mande
/newbot. - Digite o nome de exibição: "Intelecto Dev".
- Digite o handle único: precisa terminar em
_bot— ex:intelecto_nm_bot. - BotFather devolve algo como
7842913847:AAH.... Esse é o token. - Imediatamente: copie pro
.envou wizard Fernet. Nunca commite, nunca passe em chat público.
Validação do token (1 curl):
$ curl -s "https://api.telegram.org/bot${TG_TOKEN}/getMe" | jq
{
"ok": true,
"result": {
"id": 7842913847,
"is_bot": true,
"first_name": "Intelecto Dev",
"username": "intelecto_nm_bot",
"can_join_groups": true,
"can_read_all_group_messages": false,
"supports_inline_queries": false
}
}
Se ok: false, token errado ou revogado. Volte ao BotFather com /revoke e gere novo.
💡 Comandos de polish que ninguém roda (mas devia)
/setdescription— 1 frase que aparece ao abrir o bot pela primeira vez. Faça parecer útil./setuserpic— foto 512x512 PNG. Bot sem avatar passa golpe./setcommands— lista de comandos (/start, /help, /esqueca) que aparece no menu "/" do app./setprivacyem DISABLE — só se o bot for usado em grupos e precisar ler todas as mensagens.
Vazou = alguém manda em nome do bot. Revogue na hora.
Plataforma é grátis. Custo é só do LLM por trás.
Reserve cedo. Bons nomes já foram.
Tenha bot dev, staging, prod com tokens distintos.
🔁 Long-polling vs webhook — qual escolher
Telegram Bot API te dá duas formas de receber mensagens. Long-polling: seu processo pergunta "tem novidade?" e fica aguardando até 50 segundos por uma resposta. Webhook: você cadastra uma URL HTTPS pública e o Telegram bate nela quando chega mensagem. Para a maioria dos casos do livro intelecto, a resposta é long-polling — e essa decisão sozinha economiza dias de devops.
✓ Long-polling vence quando…
- ✓Você roda no laptop, num droplet, num Raspberry — qualquer máquina sem IP público.
- ✓Não quer cuidar de certificado TLS / Let's Encrypt / proxy reverso.
- ✓Volume baixo a médio (até ~30 msg/seg por bot).
- ✓Quer deploy em <1 dia sem firewall ticket no time de infra.
📡 Webhook vence quando…
- →Você já tem infra HTTPS rodando e quer 1 worker a mais nela.
- →Volume alto (centenas de msg/seg) e latência precisa ser <100ms.
- →Quer rodar serverless (Lambda, Cloud Run) — paga só quando há mensagem.
- →Múltiplas instâncias horizontalmente atrás de load balancer.
Anatomia de uma rodada de long-polling:
GET https://api.telegram.org/bot{TOKEN}/getUpdates
?offset={last_update_id + 1}
&timeout=30
&allowed_updates=["message","callback_query"]
# Resposta (vazia ou com batch):
{
"ok": true,
"result": [
{
"update_id": 819374821,
"message": {
"message_id": 47,
"from": {"id": 12345, "first_name": "Nei"},
"chat": {"id": 12345, "type": "private"},
"date": 1715980800,
"text": "oi agente"
}
}
]
}
⚠️ O gotcha do offset
Telegram só confirma que recebeu o update quando você manda o próximo getUpdates com offset = max(update_id) + 1. Esquecer disso = você processa a mesma mensagem em loop infinito até estourar conta.
Regra: só avance o offset DEPOIS de processar com sucesso. Se o handler falhar, mantenha o offset — Telegram redelivery no próximo loop. Idempotência por update_id resolve as ambiguidades.
Sweet spot. Maior = NAT mata conexão.
Filtre tipos. Sem isso, vem CHAT_MEMBER, MY_CHAT_MEMBER, ruído.
Dois processos polling o mesmo token = leitura intercalada e bugs.
Trocar pra webhook é 1 setWebhook + deleteWebhook. Sem refactor.
📨 TelegramChannel implementando Channel
A classe abaixo é a implementação canônica do contrato Channel do módulo 2.2. Sem dependência de biblioteca python-telegram-bot — só httpx.AsyncClient direto na Bot API. Resultado: 50 linhas, zero magic, transparente para debugar. Copie, cole, ajuste o token, roda.
channels/telegram.py
import asyncio, httpx, logging
from typing import Callable, Awaitable
from .base import Channel, IncomingMessage, OutgoingMessage
log = logging.getLogger(__name__)
API = "https://api.telegram.org/bot{token}/{method}"
class TelegramChannel(Channel):
def __init__(self, token: str, allowed_user_ids: set[int] | None = None):
self.token = token
self.allowed = allowed_user_ids or set()
self.client = httpx.AsyncClient(timeout=35.0)
self._offset = 0
self._running = False
async def start(self, on_message: Callable[[IncomingMessage], Awaitable[None]]) -> None:
self._running = True
log.info("telegram.start", extra={"allowed": len(self.allowed)})
while self._running:
try:
r = await self.client.get(API.format(token=self.token, method="getUpdates"),
params={"offset": self._offset, "timeout": 30,
"allowed_updates": '["message"]'})
r.raise_for_status()
for u in r.json().get("result", []):
self._offset = u["update_id"] + 1
msg = u.get("message") or {}
chat_id = msg.get("chat", {}).get("id")
text = msg.get("text") or ""
if self.allowed and chat_id not in self.allowed:
await self.send(OutgoingMessage(user_id=str(chat_id),
text="⛔ Bot privado. Solicite acesso ao admin."))
log.warning("telegram.denied", extra={"chat_id": chat_id})
continue
await on_message(IncomingMessage(
user_id=str(chat_id), text=text, timestamp=msg["date"],
metadata={"message_id": msg["message_id"], "voice": msg.get("voice")}))
except httpx.HTTPError as e:
log.error("telegram.poll_error", exc_info=e)
await asyncio.sleep(3) # backoff
async def send(self, m: OutgoingMessage) -> str:
for chunk in _chunks(m.text, 4000):
r = await self.client.post(API.format(token=self.token, method="sendMessage"),
json={"chat_id": int(m.user_id), "text": chunk,
"parse_mode": "MarkdownV2" if m.parse_mode == "markdown" else None,
"reply_to_message_id": int(m.reply_to) if m.reply_to else None})
r.raise_for_status()
return str(r.json()["result"]["message_id"])
async def stop(self) -> None:
self._running = False
await self.client.aclose()
def _chunks(s: str, n: int):
for i in range(0, len(s) or 1, n): yield s[i:i+n] or " "
🧠 Decisões que parecem pequenas mas pesam
- timeout=35s no client — 5s a mais que
timeout=30do polling. Evita race entre Telegram fechando e cliente abortando. - _offset é instance attr — não persiste em disco. Se o processo reiniciar, redelivery últimos updates. Aceitável porque handlers são idempotentes (T2.2).
- chunking em 4000 chars — limite de sendMessage é 4096. Folga de 96 evita encoding bugs com emoji.
- on_message é await direto — não
create_task. Backpressure natural: se LLM trava, polling pausa, não acumula em memória. - backoff fixo 3s — exponencial é overkill aqui. Erros de polling são quase sempre rede transitória.
💡 Como rodar agora
import os, asyncio
from channels.telegram import TelegramChannel
from base import IncomingMessage, OutgoingMessage
async def echo(msg: IncomingMessage):
await ch.send(OutgoingMessage(user_id=msg.user_id, text=f"echo: {msg.text}"))
ch = TelegramChannel(token=os.environ["TG_TOKEN"],
allowed_user_ids={int(os.environ["MY_TG_ID"])})
asyncio.run(ch.start(echo))
Só httpx. Nada de wrapper grosso.
Implementa Channel. Agent não sabe que é Telegram.
3 log lines (start, denied, poll_error) cobrem 95% do debug.
📝 MarkdownV2 — formatação sem quebrar bot
LLM gosta de markdown. Telegram aceita markdown. Parece simples — não é. MarkdownV2 exige escape de 18 caracteres reservados (_ * [ ] ( ) ~ ` > # + - = | { } . !), e qualquer um não escapado faz a API retornar 400 Bad Request e a mensagem inteira não chega. Você descobre em produção, com cliente vendo "ops, bot offline".
🚨 O bug clássico
LLM responde "Custou R$ 1.299,90 — bem mais barato!". O ponto antes do 90 e o travessão quebram MarkdownV2. Telegram rejeita silenciosamente (do ponto de vista do user) e seu bot parece offline.
400 Bad Request: can't parse entities: Character '.' is reserved and must be escaped with the preceding '\'
Escaper canônico + fallback automático:
import re
_MDV2_SPECIAL = r'_*[]()~`>#+-=|{}.!'
_MDV2_RE = re.compile('([' + re.escape(_MDV2_SPECIAL) + '])')
def escape_mdv2(text: str) -> str:
"""Escapa todos os reservados FORA de blocos de código."""
parts, out = text.split('```'), []
for i, part in enumerate(parts):
if i % 2 == 1: # bloco de código: preserva intacto
out.append('```' + part + '```')
else:
out.append(_MDV2_RE.sub(r'\\\1', part))
return ''.join(out)
async def send_safe(ch, user_id, text):
"""Tenta MarkdownV2; se falhar, manda como plain."""
try:
await ch.send(OutgoingMessage(user_id=user_id,
text=escape_mdv2(text), parse_mode="markdown"))
except httpx.HTTPStatusError as e:
if e.response.status_code == 400:
log.warning("mdv2.fallback", extra={"err": e.response.text[:200]})
await ch.send(OutgoingMessage(user_id=user_id,
text=text, parse_mode="plain"))
else:
raise
✓ Fazer
- • Escapar antes de enviar, nunca confiar no LLM.
- • Preservar blocos
```python ... ```intactos. - • Fallback automático pra plain em erro 400.
- • Logar tentativas falhas pra ajustar prompt do LLM.
✗ Evitar
- • Usar Markdown (v1) — deprecated, suporte irregular.
- • Pedir pro LLM "escapar markdown" no system prompt — alucinabilíssimo.
- • Misturar HTML e MarkdownV2 no mesmo bot.
- • Esquecer que
.e!precisam escape (sempre esquecem).
📊 Alternativa: parse_mode=HTML
Telegram também aceita HTML (subset: <b>, <i>, <code>, <pre>, <a>). Só precisa escapar &, <, >. Mais simples — mas LLM cospe markdown por default, então você gasta tokens convertendo. Para a maioria, MarkdownV2 + escaper compensa.
Reservados. Decore: .!-=() são os esquecidos.
Preservar intacto. Reservados lá dentro não contam.
Plain text sempre passa. Use como safety net.
Tenha string com todos 18 chars no test suite.
🔒 Whitelist — antes que descubram seu bot
Bots Telegram são públicos por design. Qualquer pessoa que descubra o handle (@intelecto_nm_bot) consegue mandar mensagem. Scrapers varrem o Telegram 24/7 procurando bots novos pra catalogar — e LLMs caros expostos viram alvo. Sem whitelist, sua primeira surpresa é uma fatura de US$ 380 num bot que era pra ser pessoal.
🚨 Caso real — anonimizado
Bot pessoal criado num sábado, GPT-4 atrás. Dev esqueceu whitelist. Quinta-feira: 12.000 mensagens de 47 chat_ids desconhecidos, fatura de OpenRouter US$ 380. Reclamação: "mas eu nunca compartilhei o handle". Não precisa. Telegram tem indexação pública via @userinfobot e dezenas de scrapers que listam bots novos diariamente.
.env + carregamento:
# .env
TG_TOKEN=7842913847:AAH...
ALLOWED_USER_IDS=12345,67890,11223 # vírgula, sem espaço
ADMIN_USER_ID=12345
# config.py
import os
ALLOWED = {int(x) for x in os.environ.get("ALLOWED_USER_IDS","").split(",") if x}
ADMIN = int(os.environ.get("ADMIN_USER_ID", "0"))
Gate com onboarding amigável:
DENY_MSG = (
"🔒 Este bot é privado.\n\n"
"Seu ID: `{chat_id}`\n"
"Envie esse número ao admin para liberar acesso.\n\n"
"_Se isso foi um engano, pode ignorar — nenhuma mensagem foi processada._"
)
async def gate(msg: IncomingMessage, allowed: set[int], admin: int) -> bool:
chat_id = int(msg.user_id)
if chat_id in allowed:
return True
# log + alerta para admin (1x por user_id, com cooldown)
log.warning("access_denied", extra={"chat_id": chat_id, "text": msg.text[:80]})
await audit_save(event="access_denied", chat_id=chat_id, text=msg.text)
if should_alert(chat_id): # cooldown 1h por chat_id
await ch.send(OutgoingMessage(user_id=str(admin),
text=f"🚨 Tentativa de acesso: `{chat_id}` — \"{msg.text[:60]}\""))
await ch.send(OutgoingMessage(user_id=str(chat_id),
text=DENY_MSG.format(chat_id=chat_id)))
return False
📊 Camadas de defesa
- 1. Whitelist estática (env) — barreira primária.
- 2. Audit log — toda tentativa negada vai pra disco/sqlite.
- 3. Alert admin com cooldown — você fica sabendo, sem flood.
- 4. Rate limit por chat_id — mesmo pra autorizados (T3.4).
- 5. Kill switch —
BOT_DISABLED=1derruba sem deploy.
💡 Como descobrir seu chat_id
Fale com @userinfobot no Telegram. Ele responde com seu ID numérico. Adicione ao ALLOWED_USER_IDS e reinicie.
Para descobrir o ID de outro user que pediu acesso: você já tem — está no log de access_denied.
Lookup O(1). Em bot público com 10k users, faz diferença.
chat_id é int. Comparar string ↔ int = bug silencioso.
LGPD pede. Mostre quem tentou e quando.
Não trate como ataque. 80% é curioso bem-intencionado.
🎤 Voz e áudio — Whisper-on-the-edge
Voz é o feature que mais aumenta uso em bot mobile. Pessoas falam 3x mais do que digitam, e Telegram tem botão de voice nativo a 1 toque. O fluxo: usuário aperta o microfone, Telegram manda update com voice.file_id, você baixa o OGG, transcreve com Whisper, processa o texto como mensagem normal. Whisper rodando local (faster-whisper, modelo small ou medium) é grátis, <500ms por áudio de 30s num laptop M1, e mantém áudio sensível fora de API terceira.
Download + transcrição:
from faster_whisper import WhisperModel
import httpx, tempfile, os, asyncio
from dataclasses import replace
# Carrega 1x no startup. small = 244MB, medium = 769MB
_model = WhisperModel("small", device="cpu", compute_type="int8")
async def download_voice(token: str, file_id: str) -> str:
"""Baixa OGG do Telegram, retorna path local."""
async with httpx.AsyncClient(timeout=30) as c:
meta = (await c.get(
f"https://api.telegram.org/bot{token}/getFile",
params={"file_id": file_id})).json()
path = meta["result"]["file_path"]
ogg = (await c.get(
f"https://api.telegram.org/file/bot{token}/{path}")).content
fd, fp = tempfile.mkstemp(suffix=".ogg")
os.write(fd, ogg); os.close(fd)
return fp
def transcribe(path: str, language: str = "pt") -> str:
segments, _ = _model.transcribe(path, language=language, beam_size=1)
return " ".join(s.text.strip() for s in segments).strip()
# Integração no handler:
async def on_message(msg: IncomingMessage):
voice = msg.metadata.get("voice")
if voice:
path = await download_voice(TG_TOKEN, voice["file_id"])
try:
text = await asyncio.to_thread(transcribe, path)
log.info("voice.transcribed", extra={"len": len(text), "dur": voice["duration"]})
msg = replace(msg, text=text)
finally:
os.unlink(path)
await agent.handle(msg)
📊 Local vs API — quando escolher cada
- faster-whisper local: dev, dados sensíveis, <100 áudios/dia. Custo zero, latência ~1x duração.
- OpenAI Whisper API: produção alta-escala, sem GPU local. US$ 0.006/min, latência fixa <3s.
- Groq Whisper: caso especial — 100x real-time, US$ 0.02/h. Use quando latência importa mais que custo.
✓ Pattern de fallback
try:
text = transcribe_local(path)
except Exception:
log.warning("whisper.local_fail")
text = await transcribe_api(path)
if not text.strip():
await ch.send(OutgoingMessage(
user_id=msg.user_id,
text="Não entendi o áudio. Pode digitar?"))
return
⚠️ Armadilhas que custam horas
- • Voice ≠ audio_file: Telegram tem 2 tipos.
voiceé botão de microfone (OGG).audioé arquivo MP3/M4A anexado. Trate ambos ou explicite. - • Modelo bloqueante:
WhisperModel.transcribeé CPU-bound síncrono. Semasyncio.to_thread, trava o event loop e bot fica unresponsive. - • Carregar modelo no handler: faz lazy load custar 5-10s por áudio. Sempre carregue no startup.
- • language=None: deixa Whisper detectar — adiciona ~300ms. Se você sabe que é pt-BR, force.
- • Limite de 20MB: voice messages do Telegram passam de 20MB se duração > ~12min.
getFilefalha sem mensagem clara — validevoice.durationantes.
💡 Bonus: confirmação visual
Antes de processar voice longo, mande sendChatAction(typing) para mostrar que o bot está "digitando". Telegram derruba esse status em 5s, então re-envie a cada 4s enquanto Whisper roda. UX vira premium — user sabe que mensagem chegou.
Sweet spot pt-BR. 244MB, qualidade boa.
3x menos RAM, perda imperceptível.
Greedy. Áudio conversacional não precisa de beam search.
Disco enche rápido. Sempre limpe.
📝 Resumo do módulo
getMe, polish com /setdescription e /setcommands.Próximo módulo:
2.4 — Memória que dura: SQLite FTS5, categorias, embeddings opcionais e o comando /esqueça