package bot
import (
"errors"
"fmt"
"html"
"strconv"
"strings"
"time"
cepis "cep.is/go"
"cep.is/telegrambot/internal/lookup"
)
// ParseMode is the formatting Telegram applies to every message this bot sends.
// HTML is used rather than Markdown because address data is arbitrary text from
// the Correios database and escaping three characters is a complete defence,
// while Markdown has a dozen context-dependent ones.
const ParseMode = "HTML"
// FormatAddress renders an address as the HTML body of a Telegram message.
//
// The coordinate section is where this function earns its keep. A CEP whose
// precisao is "logradouro" gets a map link: the point is a stretch of street
// and following it lands where the user expects. A CEP whose precisao is
// "localidade" gets no link at all, because the CEP is the entire municipality
// and its point is the centroid — dropping a pin there looks exactly like an
// address and is wrong by kilometres. That case is labelled, given its radius
// in kilometres, and told plainly not to be used as a delivery point.
func FormatAddress(address *cepis.Address) string {
var b strings.Builder
b.WriteString("📮 " + esc(address.FormattedCEP()) + "\n")
if line := streetLine(address); line != "" {
b.WriteString(esc(line) + "\n")
}
if address.Unidade != "" {
b.WriteString(esc(address.Unidade) + "\n")
}
b.WriteString(esc(placeLine(address)) + "\n")
if line := metaLine(address); line != "" {
b.WriteString("" + esc(line) + "\n")
}
b.WriteString(geoSection(address))
return strings.TrimRight(b.String(), "\n")
}
// InlineTitle is the one-line label shown in the inline results list.
func InlineTitle(address *cepis.Address) string {
if address.Logradouro != "" {
return address.FormattedCEP() + " — " + address.Logradouro
}
return address.FormattedCEP() + " — " + address.Localidade
}
// InlineDescription is the second line of an inline result. It repeats the
// municipality warning, so someone picking a result from the list sees it
// before sending the message rather than after.
func InlineDescription(address *cepis.Address) string {
parts := []string{placeLine(address)}
if address.Geo != nil && !address.Geo.IsPrecise() {
parts = append(parts, "CEP geral do município")
}
return strings.Join(parts, " · ")
}
func streetLine(address *cepis.Address) string {
parts := []string{}
if address.Logradouro != "" {
parts = append(parts, address.Logradouro)
}
if address.Complemento != "" {
parts = append(parts, address.Complemento)
}
return strings.Join(parts, ", ")
}
func placeLine(address *cepis.Address) string {
place := address.Localidade
if address.UF != "" {
place += "/" + address.UF
}
if address.Bairro != "" {
return address.Bairro + " — " + place
}
return place
}
func metaLine(address *cepis.Address) string {
parts := []string{}
if address.Regiao != "" {
parts = append(parts, address.Regiao)
}
if address.DDD != "" {
parts = append(parts, "DDD "+address.DDD)
}
if address.IBGE != "" {
parts = append(parts, "IBGE "+address.IBGE)
}
return strings.Join(parts, " · ")
}
func geoSection(address *cepis.Address) string {
geo := address.Geo
if geo == nil {
return "\nSem coordenada para este CEP na base do IBGE.\n"
}
if geo.IsPrecise() {
return "\n🗺 Ver no mapa\n" +
"Precisão de logradouro — o ponto é o trecho de rua (raio aprox. " +
esc(formatRadius(geo.RaioM)) + ").\n"
}
return "\n⚠️ CEP geral de município — não corresponde a um endereço.\n" +
"A única coordenada disponível é o centroide de " + esc(address.Localidade) +
", com raio aproximado de " + esc(formatRadius(geo.RaioM)) +
". Não serve como ponto de entrega nem para calcular frete, " +
"por isso não mando link de mapa.\n" +
"" + esc(coordText(geo.Lat, geo.Lon)) + "\n"
}
func mapURL(lat, lon float64) string {
latText := strconv.FormatFloat(lat, 'f', 6, 64)
lonText := strconv.FormatFloat(lon, 'f', 6, 64)
return "https://www.openstreetmap.org/?mlat=" + latText + "&mlon=" + lonText +
"#map=17/" + latText + "/" + lonText
}
func coordText(lat, lon float64) string {
return "Centroide: " + strconv.FormatFloat(lat, 'f', 6, 64) + ", " +
strconv.FormatFloat(lon, 'f', 6, 64)
}
func formatRadius(metres int) string {
if metres <= 0 {
return "desconhecido"
}
if metres < 1000 {
return strconv.Itoa(metres) + " m"
}
return strings.Replace(fmt.Sprintf("%.1f km", float64(metres)/1000), ".", ",", 1)
}
// ErrorMessage translates a failure from the cep.is client into a sentence in
// Portuguese that says what happened and what the user can do about it.
//
// Every branch is spelled out. A user who gets no answer assumes the bot is
// broken and stops using it, and "não encontrado" and "a API está fora" call
// for completely different reactions.
func ErrorMessage(err error) string {
switch {
case errors.Is(err, cepis.ErrCEPNotFound):
return notFoundMessage(err)
case errors.Is(err, cepis.ErrInvalidCEP):
return "✋ CEP inválido. Preciso de oito dígitos: 01001000 " +
"ou 01001-000."
case errors.Is(err, cepis.ErrRateLimited):
return "🚦 A API está limitando as consultas neste momento. " +
"Tente de novo em alguns instantes."
case errors.Is(err, cepis.ErrServer):
return "💥 A API da cep.is respondeu com erro. " +
"Não é problema do seu CEP — tente de novo em alguns minutos."
case errors.Is(err, cepis.ErrNetwork):
return "🔌 Não consegui falar com a cep.is. " +
"Pode ser instabilidade de rede — tente de novo em alguns minutos."
case errors.Is(err, cepis.ErrInvalidResponse):
return "🤔 A cep.is respondeu algo que não consegui interpretar. " +
"Tente de novo em alguns minutos."
}
return "⚠️ Não consegui consultar este CEP agora. Tente de novo em alguns minutos."
}
// The two halves of the not-found answer, unchanged from the day the bot
// shipped. Whatever is said about the municipality goes between them: the reply
// opens by saying the CEP does not exist and closes by saying what to do, so
// the middle cannot be mistaken for an address.
const (
notFoundHead = "🔍 CEP não encontrado na base dos Correios (DNE)."
notFoundAdvice = "Confira os oito dígitos — a base é atualizada a cada quinze dias."
)
// notFoundMessage answers a CEP that does not exist, naming the municipality it
// would belong to when the API told us which one.
//
// It is worth the extra lines. The CEP that fails is almost never a random
// number — it is someone who knows the city and guessed the rest, most often at
// the "-000" that 957 municipalities never received. Telling that person only
// "não encontrei" wastes the trip; telling them the number would have been in
// Areal/RJ says which half of their guess was right.
//
// What it must never do is read like a hit. There is no 📮, no address block
// and no CEP suggested from inside the range: the range is sparse — Areal's 118
// CEPs are spread over nearly five thousand numbers — so its ends are the
// extremes of what exists, not a list of what is valid, and the reply says so.
func notFoundMessage(err error) string {
faixa := lookup.FaixaOf(err)
if faixa == nil || faixa.Localidade == "" {
return notFoundHead + "\n" + notFoundAdvice
}
var b strings.Builder
b.WriteString(notFoundHead + "\n")
b.WriteString("Ele cairia em " + esc(faixa.Place()) + "")
count := faixaCount(faixa)
span := faixaSpan(faixa)
switch {
case count != "" && span != "":
b.WriteString(", que tem " + count + ", " + span + ".\n")
b.WriteString("A faixa é esparsa: nem todo número entre os dois é um CEP válido.\n")
case count != "":
b.WriteString(", que tem " + count + ".\n")
case span != "":
b.WriteString(", com CEPs " + span + ".\n")
b.WriteString("A faixa é esparsa: nem todo número entre os dois é um CEP válido.\n")
default:
b.WriteString(".\n")
}
b.WriteString(notFoundAdvice)
return b.String()
}
// faixaCount renders how many CEPs the municipality has, or "" when the API did
// not say. Without it the two ends of the range read as a count of one big
// block, which is the misreading the number exists to prevent.
func faixaCount(faixa *lookup.Faixa) string {
switch {
case faixa.CEPs <= 0:
return ""
case faixa.CEPs == 1:
return "1 CEP"
}
return strconv.Itoa(faixa.CEPs) + " CEPs"
}
// faixaSpan renders the ends of the range, or "" when they are missing or are
// the same number — "de X a X" is not a range, and printing a single CEP there
// would hand the user a guess to try.
func faixaSpan(faixa *lookup.Faixa) string {
first, last := maskCEP(faixa.CEPIni), maskCEP(faixa.CEPFim)
if first == "" || last == "" || first == last {
return ""
}
return "de " + esc(first) + " a " + esc(last) + ""
}
// maskCEP puts the mask on eight digits, and returns "" for anything else: a
// CEP the API sent in a shape this bot does not recognise is left out of the
// sentence rather than printed raw.
func maskCEP(cep string) string {
cep = strings.TrimSpace(cep)
if len(cep) != 8 {
return ""
}
for _, r := range cep {
if r < '0' || r > '9' {
return ""
}
}
return cep[:5] + "-" + cep[5:]
}
// RateLimitMessage is what a user held by the local token bucket is told, once
// per warning window.
func RateLimitMessage(retryAfter time.Duration) string {
seconds := int(retryAfter.Seconds())
if seconds < 1 {
seconds = 1
}
return "🐢 Calma — muitas consultas seguidas. Tente de novo em " +
strconv.Itoa(seconds) + "s."
}
// HelpMessage explains the three ways to use the bot. The username comes from
// getMe, so the examples are copy-pasteable for whichever bot the operator
// created with @BotFather.
func HelpMessage(username string) string {
at := "@" + username
return "cep.is — consulta de CEP na base oficial dos Correios (DNE).\n\n" +
"Aqui no privado\n" +
"Mande o CEP e pronto: 01001000 ou 01001-000.\n\n" +
"Em grupos e canais\n" +
"Use /cep 01001000. Se houver outros bots, " +
"/cep" + esc(at) + " 01001000 também funciona.\n\n" +
"Em qualquer conversa\n" +
"Digite " + esc(at) + " 01001000 e escolha o resultado. " +
"Não precisa me adicionar ao grupo.\n\n" +
"Cerca de 57% dos CEPs têm coordenada. Quando o CEP é geral do " +
"município, eu aviso em vez de mandar um pino no mapa.\n\n" +
"API pública e documentação: https://cep.is"
}
// StartMessage is the answer to /start: a greeting plus the whole of
// [HelpMessage], because a new user pressing Start has no other instructions.
func StartMessage(username string) string {
return "👋 Olá! Eu consulto CEP.\n\n" + HelpMessage(username)
}
// UsageMessage is the answer to a bare /cep with no argument.
func UsageMessage(username string) string {
return "Use /cep 01001000.\n" +
"Em qualquer conversa também dá para usar @" + esc(username) +
" 01001000."
}
// UnknownMessage is the answer to text in a private chat that is not a CEP and
// not a command. It is only ever sent in private: answering unrecognised text
// in a group would make the bot a nuisance.
const UnknownMessage = "Não entendi. Mande um CEP (01001000) " +
"ou use /help."
func esc(text string) string { return html.EscapeString(text) }