diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 15:18:21 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 15:18:21 -0300 |
| commit | 3ca1d2c4370e8e79aba3940d5c8fe115960b75a8 (patch) | |
| tree | aa964675c588b80e4f81182d34130ba2d7d984e1 /internal/bot/format.go | |
| download | telegram-3ca1d2c4370e8e79aba3940d5c8fe115960b75a8.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'internal/bot/format.go')
| -rw-r--r-- | internal/bot/format.go | 225 |
1 files changed, 225 insertions, 0 deletions
diff --git a/internal/bot/format.go b/internal/bot/format.go new file mode 100644 index 0000000..9a9a22d --- /dev/null +++ b/internal/bot/format.go @@ -0,0 +1,225 @@ +package bot + +import ( + "errors" + "fmt" + "html" + "strconv" + "strings" + "time" + + cepis "cep.is/go" +) + +// 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("📮 <b>" + esc(address.FormattedCEP()) + "</b>\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("<i>" + esc(line) + "</i>\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 "\n<i>Sem coordenada para este CEP na base do IBGE.</i>\n" + } + + if geo.IsPrecise() { + return "\n🗺 <a href=\"" + esc(mapURL(geo.Lat, geo.Lon)) + "\">Ver no mapa</a>\n" + + "<i>Precisão de logradouro — o ponto é o trecho de rua (raio aprox. " + + esc(formatRadius(geo.RaioM)) + ").</i>\n" + } + + return "\n⚠️ <b>CEP geral de município</b> — 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" + + "<i>" + esc(coordText(geo.Lat, geo.Lon)) + "</i>\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 "🔍 CEP não encontrado na base dos Correios (DNE).\n" + + "Confira os oito dígitos — a base é atualizada a cada quinze dias." + case errors.Is(err, cepis.ErrInvalidCEP): + return "✋ CEP inválido. Preciso de oito dígitos: <code>01001000</code> " + + "ou <code>01001-000</code>." + 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." +} + +// 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 "<b>cep.is</b> — consulta de CEP na base oficial dos Correios (DNE).\n\n" + + "<b>Aqui no privado</b>\n" + + "Mande o CEP e pronto: <code>01001000</code> ou <code>01001-000</code>.\n\n" + + "<b>Em grupos e canais</b>\n" + + "Use <code>/cep 01001000</code>. Se houver outros bots, " + + "<code>/cep" + esc(at) + " 01001000</code> também funciona.\n\n" + + "<b>Em qualquer conversa</b>\n" + + "Digite <code>" + esc(at) + " 01001000</code> 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 <code>/cep 01001000</code>.\n" + + "Em qualquer conversa também dá para usar <code>@" + esc(username) + + " 01001000</code>." +} + +// 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 (<code>01001000</code>) " + + "ou use /help." + +func esc(text string) string { return html.EscapeString(text) } |