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) }