diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:46:40 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:46:40 -0300 |
| commit | 83ce95cf10fd9e9d581362453c558239d37ae06e (patch) | |
| tree | dcaf1e9258e1c55c2df19f6556e15c39eaf622bb /internal/bot | |
| parent | 3ca1d2c4370e8e79aba3940d5c8fe115960b75a8 (diff) | |
| download | telegram-83ce95cf10fd9e9d581362453c558239d37ae06e.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.go | 21 | ||||
| -rw-r--r-- | internal/bot/format.go | 96 | ||||
| -rw-r--r-- | internal/bot/format_test.go | 141 |
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 <b>Rita</b> & Cia/M&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"} { |