summaryrefslogtreecommitdiffstats
path: root/internal/bot/format.go
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 15:18:21 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 15:18:21 -0300
commit3ca1d2c4370e8e79aba3940d5c8fe115960b75a8 (patch)
treeaa964675c588b80e4f81182d34130ba2d7d984e1 /internal/bot/format.go
downloadtelegram-507b9f5009ff8720e0ea9a43c2f6bf888fd53ddb.tar.gz
Initial importv0.1.0
Diffstat (limited to 'internal/bot/format.go')
-rw-r--r--internal/bot/format.go225
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) }