summaryrefslogtreecommitdiffstats
path: root/internal/bot
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:46:40 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:46:40 -0300
commit83ce95cf10fd9e9d581362453c558239d37ae06e (patch)
treedcaf1e9258e1c55c2df19f6556e15c39eaf622bb /internal/bot
parent3ca1d2c4370e8e79aba3940d5c8fe115960b75a8 (diff)
downloadtelegram-6a856ec45a9182237efa0f779b52a8bf6ac0e7dc.tar.gz
Name the municipality when the CEP does not existv0.2.0
Answering only "não encontrei" wastes the trip. A 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. The API now names that municipality on the 404, so the reply names it too. It stays an answer to a failure. No 📮, no address block, and no CEP suggested from inside the range — the range is sparse (Areal's 118 CEPs span nearly five thousand numbers), so the reply gives the count and says so instead of letting the two ends read as a list of valid CEPs. cep.is/go v0.1.0 discards the 404 body, so internal/lookup reads it with one extra GET, on that path alone and never inside a retry. Everything that can go wrong there — a slow answer, broken JSON, a faixa naming no municipality — falls back to the reply the bot has always given. The wrapper goes away the day the library exposes the field.
Diffstat (limited to 'internal/bot')
-rw-r--r--internal/bot/bot.go21
-rw-r--r--internal/bot/format.go96
-rw-r--r--internal/bot/format_test.go141
3 files changed, 255 insertions, 3 deletions
diff --git a/internal/bot/bot.go b/internal/bot/bot.go
index b29dc36..4abf02c 100644
--- a/internal/bot/bot.go
+++ b/internal/bot/bot.go
@@ -13,11 +13,16 @@ import (
cepis "cep.is/go"
+ "cep.is/telegrambot/internal/lookup"
"cep.is/telegrambot/internal/telegram"
)
// Lookups is the subset of the cep.is client this bot needs. Depending on the
// interface rather than the concrete client keeps the seam narrow and visible.
+//
+// A failure may be a [*lookup.NotFoundError], which still matches
+// cepis.ErrCEPNotFound and additionally names the municipality the CEP would
+// belong to; nothing here has to know that, because [ErrorMessage] reads it.
type Lookups interface {
Lookup(ctx context.Context, cep string) (*cepis.Address, error)
}
@@ -211,7 +216,7 @@ func (b *Bot) handleInline(ctx context.Context, query *telegram.InlineQuery) err
if err != nil {
b.logger.Info("inline lookup failed", slog.String("error", err.Error()))
return b.answerInline(ctx, query, []telegram.InlineQueryResult{
- b.inlineNotice(query, inlineErrorTitle(err), "Toque para enviar o motivo", ErrorMessage(err)),
+ b.inlineNotice(query, inlineErrorTitle(err), inlineErrorDescription(err), ErrorMessage(err)),
}, inlineCacheOther)
}
@@ -260,6 +265,20 @@ func inlineErrorTitle(err error) string {
return "Não consegui consultar agora"
}
+// inlineErrorDescription is the second line of the notice. Naming the
+// municipality here is what lets someone see, in the results list, that the
+// city they had in mind is right and the number is not — before sending
+// anything to the chat.
+//
+// Titles and descriptions are plain text for Telegram, so nothing here is
+// escaped: HTML applies to the message body alone.
+func inlineErrorDescription(err error) string {
+ if faixa := lookup.FaixaOf(err); faixa != nil {
+ return "Cairia em " + faixa.Place() + " — toque para ver"
+ }
+ return "Toque para enviar o motivo"
+}
+
// rateKey is the token bucket's key: the author when there is one, and the chat
// otherwise. A channel post has no author, and channel ids are negative, so the
// two spaces cannot collide.
diff --git a/internal/bot/format.go b/internal/bot/format.go
index 9a9a22d..b94dee4 100644
--- a/internal/bot/format.go
+++ b/internal/bot/format.go
@@ -9,6 +9,8 @@ import (
"time"
cepis "cep.is/go"
+
+ "cep.is/telegrambot/internal/lookup"
)
// ParseMode is the formatting Telegram applies to every message this bot sends.
@@ -152,8 +154,7 @@ func formatRadius(metres int) string {
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."
+ return notFoundMessage(err)
case errors.Is(err, cepis.ErrInvalidCEP):
return "✋ CEP inválido. Preciso de oito dígitos: <code>01001000</code> " +
"ou <code>01001-000</code>."
@@ -173,6 +174,97 @@ func ErrorMessage(err error) string {
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 <b>" + esc(faixa.Place()) + "</b>")
+
+ count := faixaCount(faixa)
+ span := faixaSpan(faixa)
+ switch {
+ case count != "" && span != "":
+ b.WriteString(", que tem " + count + ", " + span + ".\n")
+ b.WriteString("<i>A faixa é esparsa: nem todo número entre os dois é um CEP válido.</i>\n")
+ case count != "":
+ b.WriteString(", que tem " + count + ".\n")
+ case span != "":
+ b.WriteString(", com CEPs " + span + ".\n")
+ b.WriteString("<i>A faixa é esparsa: nem todo número entre os dois é um CEP válido.</i>\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 <code>" + esc(first) + "</code> a <code>" + esc(last) + "</code>"
+}
+
+// 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 {
diff --git a/internal/bot/format_test.go b/internal/bot/format_test.go
index 56b8dda..513db36 100644
--- a/internal/bot/format_test.go
+++ b/internal/bot/format_test.go
@@ -7,6 +7,8 @@ import (
"testing"
cepis "cep.is/go"
+
+ "cep.is/telegrambot/internal/lookup"
)
func decodeAddress(t *testing.T, body string) *cepis.Address {
@@ -116,6 +118,145 @@ func TestErrorMessagesAreDistinct(t *testing.T) {
}
}
+func notFound(faixa *lookup.Faixa) error {
+ return &lookup.NotFoundError{Faixa: faixa, Err: cepis.ErrCEPNotFound}
+}
+
+var areal = &lookup.Faixa{
+ Localidade: "Areal", UF: "RJ", Estado: "Rio de Janeiro", Regiao: "Sudeste",
+ IBGE: "3300225", CEPIni: "25845001", CEPFim: "25849899", CEPs: 118,
+}
+
+func TestNotFoundNamesTheMunicipality(t *testing.T) {
+ text := ErrorMessage(notFound(areal))
+
+ for _, needed := range []string{
+ "não encontrado", "Areal/RJ", "118 CEPs", "25845-001", "25849-899",
+ "esparsa", "Confira os oito dígitos",
+ } {
+ if !strings.Contains(text, needed) {
+ t.Fatalf("reply is missing %q:\n%s", needed, text)
+ }
+ }
+}
+
+// The municipality is context on a failure. A reply that looks like a hit would
+// have someone write the city down as the answer.
+func TestNotFoundDoesNotLookLikeAnAnswer(t *testing.T) {
+ text := ErrorMessage(notFound(areal))
+
+ if strings.Contains(text, "📮") {
+ t.Fatalf("the found-address marker leaked into a failure:\n%s", text)
+ }
+ if !strings.HasPrefix(text, notFoundHead) {
+ t.Fatalf("the reply must open by saying the CEP does not exist:\n%s", text)
+ }
+ if !strings.HasSuffix(text, notFoundAdvice) {
+ t.Fatalf("the reply must close with the usual advice:\n%s", text)
+ }
+ if strings.Contains(text, "25845-000") {
+ t.Fatalf("the reply suggests the CEP that just failed:\n%s", text)
+ }
+}
+
+// Absence of a faixa must be indistinguishable from the day before this
+// existed, byte for byte.
+func TestNotFoundWithoutFaixaIsUnchanged(t *testing.T) {
+ const want = "🔍 CEP não encontrado na base dos Correios (DNE).\n" +
+ "Confira os oito dígitos — a base é atualizada a cada quinze dias."
+
+ for name, err := range map[string]error{
+ "plain sentinel": cepis.ErrCEPNotFound,
+ "no faixa": notFound(nil),
+ "empty faixa": notFound(&lookup.Faixa{}),
+ } {
+ if got := ErrorMessage(err); got != want {
+ t.Fatalf("%s:\n got %q\nwant %q", name, got, want)
+ }
+ }
+}
+
+func TestNotFoundEscapesTheMunicipality(t *testing.T) {
+ text := ErrorMessage(notFound(&lookup.Faixa{
+ Localidade: "Santa <b>Rita</b> & Cia", UF: "M&G",
+ CEPIni: "25845001", CEPFim: "25849899", CEPs: 2,
+ }))
+
+ if strings.Contains(text, "<b>Rita</b>") {
+ t.Fatalf("the municipality name must be escaped:\n%s", text)
+ }
+ if !strings.Contains(text, "Santa &lt;b&gt;Rita&lt;/b&gt; &amp; Cia/M&amp;G") {
+ t.Fatalf("escaping is wrong:\n%s", text)
+ }
+}
+
+// Every field of the faixa is optional in practice, and a missing one may not
+// take the sentence down with it.
+func TestNotFoundDegradesFieldByField(t *testing.T) {
+ cases := map[string]struct {
+ faixa *lookup.Faixa
+ present []string
+ absent []string
+ }{
+ "no count": {
+ faixa: &lookup.Faixa{Localidade: "Areal", UF: "RJ", CEPIni: "25845001", CEPFim: "25849899"},
+ present: []string{"Areal/RJ", "25845-001", "25849-899", "esparsa"},
+ absent: []string{"CEPs,"},
+ },
+ "no range": {
+ faixa: &lookup.Faixa{Localidade: "Areal", UF: "RJ", CEPs: 118},
+ present: []string{"Areal/RJ", "118 CEPs"},
+ absent: []string{"<code>", "esparsa"},
+ },
+ "only the name": {
+ faixa: &lookup.Faixa{Localidade: "Areal"},
+ present: []string{"cairia em <b>Areal</b>."},
+ absent: []string{"<code>", "esparsa", "Areal/"},
+ },
+ "single CEP": {
+ faixa: &lookup.Faixa{Localidade: "Vila", UF: "SP", CEPIni: "12345678", CEPFim: "12345678", CEPs: 1},
+ present: []string{"1 CEP."},
+ absent: []string{"12345-678"},
+ },
+ "unusable range": {
+ faixa: &lookup.Faixa{Localidade: "Areal", UF: "RJ", CEPIni: "abc", CEPFim: "2584", CEPs: 118},
+ present: []string{"118 CEPs."},
+ absent: []string{"abc", "2584"},
+ },
+ }
+
+ for name, tc := range cases {
+ t.Run(name, func(t *testing.T) {
+ text := ErrorMessage(notFound(tc.faixa))
+ for _, needed := range tc.present {
+ if !strings.Contains(text, needed) {
+ t.Fatalf("missing %q:\n%s", needed, text)
+ }
+ }
+ for _, unwanted := range tc.absent {
+ if strings.Contains(text, unwanted) {
+ t.Fatalf("should not carry %q:\n%s", unwanted, text)
+ }
+ }
+ })
+ }
+}
+
+func TestInlineErrorDescriptionNamesTheMunicipality(t *testing.T) {
+ if got := inlineErrorDescription(notFound(areal)); got != "Cairia em Areal/RJ — toque para ver" {
+ t.Fatalf("description = %q", got)
+ }
+ if got := inlineErrorDescription(cepis.ErrCEPNotFound); got != "Toque para enviar o motivo" {
+ t.Fatalf("description without a faixa = %q", got)
+ }
+ // Titles and descriptions are plain text: escaping them would show the
+ // entities to the user.
+ got := inlineErrorDescription(notFound(&lookup.Faixa{Localidade: "Santa & Cia", UF: "SP"}))
+ if !strings.Contains(got, "Santa & Cia/SP") {
+ t.Fatalf("description = %q", got)
+ }
+}
+
func TestHelpMentionsEveryEntryPoint(t *testing.T) {
text := HelpMessage("cepisbot")
for _, needed := range []string{"01001-000", "/cep 01001000", "@cepisbot 01001000", "https://cep.is"} {