Verificando acesso...

MÓDULO 2.3

💬 Canal Telegram em 50 linhas

Do /newbot ao primeiro turno em produção em menos de 30 minutos. Long-polling, MarkdownV2, whitelist e voz — implementação concreta da abstração Channel que você desenhou no módulo anterior.

6
Tópicos
70
Minutos
Técnico
Nível
Hands-on
Tipo
1

🤖 @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

  1. Abra Telegram, busque @BotFather (com ícone azul de verificado).
  2. Mande /newbot.
  3. Digite o nome de exibição: "Intelecto Dev".
  4. Digite o handle único: precisa terminar em _bot — ex: intelecto_nm_bot.
  5. BotFather devolve algo como 7842913847:AAH.... Esse é o token.
  6. Imediatamente: copie pro .env ou 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.
  • /setprivacy em DISABLE — só se o bot for usado em grupos e precisar ler todas as mensagens.
Token = senha

Vazou = alguém manda em nome do bot. Revogue na hora.

Sem custo Telegram

Plataforma é grátis. Custo é só do LLM por trás.

Handle único global

Reserve cedo. Bons nomes já foram.

Múltiplos bots OK

Tenha bot dev, staging, prod com tokens distintos.

2

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

timeout=30

Sweet spot. Maior = NAT mata conexão.

allowed_updates

Filtre tipos. Sem isso, vem CHAT_MEMBER, MY_CHAT_MEMBER, ruído.

1 polling, 1 bot

Dois processos polling o mesmo token = leitura intercalada e bugs.

Migração depois

Trocar pra webhook é 1 setWebhook + deleteWebhook. Sem refactor.

3

📨 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=30 do 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))
Dependência

Só httpx. Nada de wrapper grosso.

Trocável

Implementa Channel. Agent não sabe que é Telegram.

Observável

3 log lines (start, denied, poll_error) cobrem 95% do debug.

4

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

18 caracteres

Reservados. Decore: .!-=() são os esquecidos.

Code blocks

Preservar intacto. Reservados lá dentro não contam.

Fallback é lei

Plain text sempre passa. Use como safety net.

Test fixture

Tenha string com todos 18 chars no test suite.

5

🔒 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 switchBOT_DISABLED=1 derruba 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.

Set, não list

Lookup O(1). Em bot público com 10k users, faz diferença.

Inteiro estrito

chat_id é int. Comparar string ↔ int = bug silencioso.

Audit obrigatório

LGPD pede. Mostre quem tentou e quando.

Mensagem amigável

Não trate como ataque. 80% é curioso bem-intencionado.

6

🎤 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. Sem asyncio.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. getFile falha sem mensagem clara — valide voice.duration antes.

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

Modelo small

Sweet spot pt-BR. 244MB, qualidade boa.

int8 quantization

3x menos RAM, perda imperceptível.

beam_size=1

Greedy. Áudio conversacional não precisa de beam search.

tempfile + unlink

Disco enche rápido. Sempre limpe.

📝 Resumo do módulo

Token em 60s via @BotFather — único canal sem aprovação. Valide com getMe, polish com /setdescription e /setcommands.
Long-polling é o default certo — roda sem IP público, sem TLS, sem firewall ticket. Webhook fica pra escala alta ou serverless.
TelegramChannel em ~50 linhas — só httpx, zero wrapper. Implementa o contrato Channel intacto, hot-swap com qualquer outro canal.
MarkdownV2 exige escape e fallback — 18 chars reservados, blocos de código preservados, retry em plain text se 400.
Whitelist é dia zero, não roadmap — scrapers acham seu bot em dias. ALLOWED_USER_IDS + audit log + alert ao admin com cooldown.
Voz triplica adoção mobile — faster-whisper local, modelo small + int8, asyncio.to_thread pra não travar loop, fallback pra API.

Próximo módulo:

2.4 — Memória que dura: SQLite FTS5, categorias, embeddings opcionais e o comando /esqueça