summaryrefslogtreecommitdiffstats
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
parent3ca1d2c4370e8e79aba3940d5c8fe115960b75a8 (diff)
downloadtelegram-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.
-rw-r--r--README.md30
-rw-r--r--internal/app/app.go11
-rw-r--r--internal/app/app_test.go50
-rw-r--r--internal/app/fakes_test.go20
-rw-r--r--internal/bot/bot.go21
-rw-r--r--internal/bot/format.go96
-rw-r--r--internal/bot/format_test.go141
-rw-r--r--internal/lookup/lookup.go248
-rw-r--r--internal/lookup/lookup_test.go279
-rw-r--r--internal/telegram/client.go2
10 files changed, 890 insertions, 8 deletions
diff --git a/README.md b/README.md
index b81b553..aad82cf 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,36 @@ os dois casos: com precisão de logradouro sai um link de mapa; quando o CEP é
geral do município, sai um aviso com o raio da área em vez de um pino, porque o
ponto é o centroide da cidade e pode errar dezenas de quilómetros.
+## Quando o CEP não existe
+
+Um CEP que falha quase nunca é um número aleatório — é alguém que sabe a cidade
+e chutou o resto, quase sempre no `-000` que 957 municípios nunca receberam.
+Dizer só "não encontrei" desperdiça a viagem, então o bot diz de que cidade o
+número seria:
+
+```
+🔍 CEP não encontrado na base dos Correios (DNE).
+Ele cairia em Areal/RJ, que tem 118 CEPs, de 25845-001 a 25849-899.
+A faixa é esparsa: nem todo número entre os dois é um CEP válido.
+Confira os oito dígitos — a base é atualizada a cada quinze dias.
+```
+
+Continua sendo um erro, e a resposta é escrita para não ser confundida com um
+acerto: não é um endereço, e os dois extremos não são uma lista de CEPs
+válidos — os 118 de Areal estão espalhados por quase cinco mil números, que é
+para isso que a contagem vai junto. O bot também nunca sugere um CEP de dentro
+da faixa.
+
+A cidade vem no próprio 404 da API, no objeto `faixa`. Quando o CEP cai num
+intervalo que os Correios não deram a município nenhum, o objeto não vem e a
+resposta é a de sempre, sem a linha do meio.
+
+Por enquanto isso custa um GET a mais, e só no caminho do 404: o `cep.is/go`
+v0.1.0 joga fora o corpo da resposta 404, e é de lá que a faixa é lida
+(`internal/lookup`). Qualquer problema nessa segunda consulta — lentidão, JSON
+quebrado, faixa sem município — cai de volta na resposta de sempre. Some quando
+a biblioteca expuser o campo.
+
## Criando o bot
O username e o token são criados por você no
diff --git a/internal/app/app.go b/internal/app/app.go
index 0b78baf..ccb8483 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -15,6 +15,7 @@ import (
"cep.is/telegrambot/internal/bot"
"cep.is/telegrambot/internal/config"
+ "cep.is/telegrambot/internal/lookup"
"cep.is/telegrambot/internal/telegram"
)
@@ -34,22 +35,28 @@ type App struct {
// The whole-call timeout of the cep.is client is the shutdown grace on purpose:
// handling one update is what a termination signal waits for, so a lookup must
// not be allowed to outlast that budget.
+//
+// The client goes to the bot wrapped in [lookup.Client], which is what keeps the
+// municipality the API names on a 404. The wrapper has its own short timeout,
+// deliberately far below the grace: it is a footnote on an answer that is
+// already writable.
func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
if logger == nil {
logger = slog.Default()
}
- lookups, err := cepis.New(
+ cepisClient, err := cepis.New(
cepis.WithBaseURL(cfg.CepisBaseURL),
cepis.WithTimeout(cfg.RequestTimeout),
cepis.WithMaxAttempts(cfg.MaxAttempts),
cepis.WithBackoff(cfg.BackoffBase, cfg.BackoffMax),
cepis.WithTotalTimeout(cfg.ShutdownGrace),
- cepis.WithUserAgent("cepis-telegram/0.1.0 (+https://cep.is)"),
+ cepis.WithUserAgent("cepis-telegram/0.2.0 (+https://cep.is)"),
)
if err != nil {
return nil, fmt.Errorf("app: cep.is client: %w", err)
}
+ lookups := lookup.New(cepisClient)
api, err := telegram.New(cfg.Token,
telegram.WithBaseURL(cfg.TelegramAPIBaseURL),
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
index e770060..d430e11 100644
--- a/internal/app/app_test.go
+++ b/internal/app/app_test.go
@@ -291,6 +291,8 @@ func TestMalformedCEP(t *testing.T) {
}
}
+// A CEP in a numeric band the Correios never handed to any municipality: the
+// 404 carries no faixa, and the answer is the one the bot has always given.
func TestCEPNotFound(t *testing.T) {
tg, api := newFakeTelegram(t), newFakeCEPIS(t)
tg.push(privateMessage(1, 555, "99999999"))
@@ -300,6 +302,54 @@ func TestCEPNotFound(t *testing.T) {
if !strings.Contains(sent[0].Text, "não encontrado") {
t.Fatalf("reply = %q, want a not-found message", sent[0].Text)
}
+ if strings.Contains(sent[0].Text, "cairia em") {
+ t.Fatalf("named a municipality the API did not send: %q", sent[0].Text)
+ }
+}
+
+// The "-000" guess for Areal/RJ, which never received that number. The reply
+// still says the CEP does not exist, and adds where it would have been.
+func TestCEPNotFoundNamesTheMunicipality(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(privateMessage(1, 555, "25845-000"))
+ start(t, testConfig(tg, api))
+
+ sent := waitForMessages(t, tg, 1)
+ text := sent[0].Text
+ for _, needed := range []string{
+ "não encontrado", "Areal/RJ", "118 CEPs", "25845-001", "25849-899", "esparsa",
+ } {
+ if !strings.Contains(text, needed) {
+ t.Fatalf("reply is missing %q: %q", needed, text)
+ }
+ }
+ if strings.Contains(text, "📮") {
+ t.Fatalf("a failure must not be formatted as a hit: %q", text)
+ }
+ if strings.Contains(text, "25845-000") {
+ t.Fatalf("the reply hands back the CEP that just failed: %q", text)
+ }
+}
+
+func TestInlineNotFoundNamesTheMunicipality(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(inlineQuery(1, 555, "25845000"))
+ start(t, testConfig(tg, api))
+
+ waitFor(t, "inline answer", 10*time.Second, func() bool { return len(tg.inlineAnswers()) >= 1 })
+ results := tg.inlineAnswers()[0].Results
+ if len(results) != 1 {
+ t.Fatalf("got %d results, want 1", len(results))
+ }
+ if results[0].Title != "CEP não encontrado" {
+ t.Fatalf("title = %q", results[0].Title)
+ }
+ if !strings.Contains(results[0].Description, "Areal/RJ") {
+ t.Fatalf("description = %q, want the municipality named", results[0].Description)
+ }
+ if !strings.Contains(results[0].InputMessageContent.MessageText, "118 CEPs") {
+ t.Fatalf("message = %q", results[0].InputMessageContent.MessageText)
+ }
}
func TestAPIServerError(t *testing.T) {
diff --git a/internal/app/fakes_test.go b/internal/app/fakes_test.go
index 011d4af..9281a53 100644
--- a/internal/app/fakes_test.go
+++ b/internal/app/fakes_test.go
@@ -182,12 +182,16 @@ func (f *fakeTelegram) pendingCount() int {
// fakeCEPIS is a real HTTP server speaking the cep.is native contract, with
// enough of it to exercise every branch the bot has: an address with a street
-// coordinate, one that is a whole municipality, a 404, a 400 and a 500.
+// coordinate, one that is a whole municipality, a 404 that names the
+// municipality the CEP would fall in, a 404 that does not, a 400 and a 500.
type fakeCEPIS struct {
server *httptest.Server
- mu sync.Mutex
+ mu sync.Mutex
+ // bodies are the 200s, missing are the 404s that carry a faixa, and
+ // statuses forces a status for a CEP regardless of the other two.
bodies map[string]string
+ missing map[string]string
statuses map[string]int
requests int
}
@@ -208,6 +212,14 @@ func newFakeCEPIS(t *testing.T) *fakeCEPIS {
`"unidade":"","bairro":"Centro","localidade":"Linhares","uf":"ES","estado":"Espírito Santo",` +
`"regiao":"Sudeste","ibge":"3203205","gia":"","ddd":"27","siafi":"5665"}`,
},
+ // 25845000 is the CEP the "-000" guess produces for Areal/RJ, which
+ // is coded street by street and never received that number: the
+ // municipality exists, the CEP does not.
+ missing: map[string]string{
+ "25845000": `{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE",` +
+ `"faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro","regiao":"Sudeste",` +
+ `"ibge":"3300225","cep_ini":"25845001","cep_fim":"25849899","ceps":118}}`,
+ },
statuses: map[string]int{
"50000000": http.StatusInternalServerError,
},
@@ -225,6 +237,7 @@ func (f *fakeCEPIS) handle(w http.ResponseWriter, r *http.Request) {
cep := strings.TrimPrefix(r.URL.Path, "/")
status, forced := f.statuses[cep]
body, known := f.bodies[cep]
+ absent, attributed := f.missing[cep]
f.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -235,6 +248,9 @@ func (f *fakeCEPIS) handle(w http.ResponseWriter, r *http.Request) {
case known:
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
+ case attributed:
+ w.WriteHeader(http.StatusNotFound)
+ w.Write([]byte(absent))
case len(cep) != 8:
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"erro":"cep_invalido","mensagem":"CEP deve ter 8 dígitos"}`))
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"} {
diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go
new file mode 100644
index 0000000..956a731
--- /dev/null
+++ b/internal/lookup/lookup.go
@@ -0,0 +1,248 @@
+// Package lookup resolves a CEP through the cep.is API.
+//
+// It is a thin layer over the official client, and it exists for one thing the
+// client does not do: a 404 from GET /{cep} may carry a `faixa` block naming
+// the municipality that CEP would belong to, and cep.is/go v0.1.0 turns the
+// response into [cepis.ErrCEPNotFound] without keeping anything from the body.
+// This package puts that block back, as [NotFoundError.Faixa].
+//
+// Everything about it is best-effort. The municipality is context on top of an
+// error that is already complete without it, so every failure along the way —
+// no body, malformed JSON, a slow second request — ends as a plain
+// ErrCEPNotFound, exactly as if the API had never sent the block.
+package lookup
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ cepis "cep.is/go"
+)
+
+// defaultFaixaTimeout caps the extra request. It is deliberately short and
+// deliberately not the timeout of the lookup itself: the CEP is already known
+// not to exist, the answer is already writable, and nobody should wait longer
+// for the footnote than for the answer.
+const defaultFaixaTimeout = 2 * time.Second
+
+// maxFaixaBody is how much of the 404 body is read. The real one is around 200
+// bytes; anything past this is not the API answering.
+const maxFaixaBody = 64 << 10
+
+// Faixa is the municipality a CEP that does not exist would fall in, together
+// with the range of CEPs that municipality actually has.
+//
+// A CEP that fails is rarely a random number: it is usually someone who knows
+// the city and guessed the rest, and the classic guess is the "-000" that 957
+// municipalities never received. Areal/RJ answers from 25845-001 up and never
+// at 25845-000, so the 404 for 25845000 names Areal.
+//
+// CEPIni and CEPFim are the smallest and the largest CEP that EXIST there, not
+// the bounds the Correios publish — the DNE's own start for Areal is the
+// 25845000 that just failed. The range is sparse, which is what CEPs is for:
+// Areal's 118 CEPs are scattered over nearly five thousand numbers. Never
+// present the pair as a list of valid CEPs.
+type Faixa struct {
+ Localidade string `json:"localidade"`
+ UF string `json:"uf"`
+ Estado string `json:"estado"`
+ Regiao string `json:"regiao"`
+ IBGE string `json:"ibge"`
+ CEPIni string `json:"cep_ini"`
+ CEPFim string `json:"cep_fim"`
+
+ // CEPs is how many CEPs the municipality has inside the range.
+ CEPs int `json:"ceps"`
+}
+
+// Place is the municipality as it is written to a user: "Areal/RJ", or just
+// "Areal" when the API sent no UF.
+func (f *Faixa) Place() string {
+ if f == nil {
+ return ""
+ }
+ if f.UF == "" {
+ return f.Localidade
+ }
+ return f.Localidade + "/" + f.UF
+}
+
+// NotFoundError is [cepis.ErrCEPNotFound] with the municipality attached. It
+// matches the sentinel through errors.Is, so a caller that does not know about
+// this type keeps behaving exactly as before:
+//
+// if errors.Is(err, cepis.ErrCEPNotFound) { ... }
+type NotFoundError struct {
+ // Faixa is the municipality the CEP would belong to. It is never nil in
+ // an error this package builds: without a faixa there is nothing to add
+ // and the underlying error is returned untouched.
+ Faixa *Faixa
+
+ // Err is the error from the client library, wrapped unchanged.
+ Err error
+}
+
+// Error implements the error interface, reporting the wrapped failure verbatim:
+// the faixa is for the user, not for the log line.
+func (e *NotFoundError) Error() string {
+ if e.Err != nil {
+ return e.Err.Error()
+ }
+ return cepis.ErrCEPNotFound.Error()
+}
+
+// Unwrap keeps errors.Is(err, cepis.ErrCEPNotFound) true.
+func (e *NotFoundError) Unwrap() error {
+ if e.Err != nil {
+ return e.Err
+ }
+ return cepis.ErrCEPNotFound
+}
+
+// FaixaOf returns the municipality carried by err, or nil when there is none.
+// Any error is safe to pass, including nil.
+func FaixaOf(err error) *Faixa {
+ var notFound *NotFoundError
+ if errors.As(err, &notFound) {
+ return notFound.Faixa
+ }
+ return nil
+}
+
+// Client resolves CEPs. It is safe for concurrent use, as the client it wraps
+// is.
+type Client struct {
+ api *cepis.Client
+ httpClient *http.Client
+ timeout time.Duration
+}
+
+// Option configures a [Client].
+type Option func(*Client)
+
+// WithTimeout caps the extra request that reads the faixa off a 404.
+func WithTimeout(timeout time.Duration) Option {
+ return func(c *Client) {
+ if timeout > 0 {
+ c.timeout = timeout
+ }
+ }
+}
+
+// WithHTTPClient supplies the [net/http.Client] used for that request.
+func WithHTTPClient(httpClient *http.Client) Option {
+ return func(c *Client) {
+ if httpClient != nil {
+ c.httpClient = httpClient
+ }
+ }
+}
+
+// New wraps an already configured cep.is client.
+func New(api *cepis.Client, opts ...Option) *Client {
+ c := &Client{
+ api: api,
+ httpClient: &http.Client{},
+ timeout: defaultFaixaTimeout,
+ }
+ for _, opt := range opts {
+ if opt != nil {
+ opt(c)
+ }
+ }
+ return c
+}
+
+// Lookup resolves a single CEP.
+//
+// It behaves exactly like [cepis.Client.Lookup], except that a CEP absent from
+// the DNE may come back as a [*NotFoundError] naming the municipality. That is
+// still a failure and still matches cepis.ErrCEPNotFound; nothing about it says
+// an address was found.
+func (c *Client) Lookup(ctx context.Context, cep string) (*cepis.Address, error) {
+ address, err := c.api.Lookup(ctx, cep)
+ if err == nil || !errors.Is(err, cepis.ErrCEPNotFound) {
+ return address, err
+ }
+
+ faixa := c.faixa(ctx, cep)
+ if faixa == nil {
+ return nil, err
+ }
+ return nil, &NotFoundError{Faixa: faixa, Err: err}
+}
+
+// faixa asks for the CEP once more, only to read the block the library dropped.
+//
+// One extra GET is the price of not forking the client. It is bounded to the
+// 404 path, which is definitive and never retried, so the request cannot land
+// in a retry loop; and it returns nil for every problem it meets, because the
+// caller already has a complete answer to send without it.
+//
+// Delete this the day cep.is/go exposes the faixa itself.
+func (c *Client) faixa(ctx context.Context, cep string) *Faixa {
+ number, err := cepis.NormalizeCEP(cep)
+ if err != nil {
+ return nil
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, c.timeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.api.BaseURL()+"/"+number, nil)
+ if err != nil {
+ return nil
+ }
+ req.Header.Set("User-Agent", c.api.UserAgent())
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil
+ }
+ defer resp.Body.Close()
+
+ // Anything but a second 404 means the answer changed under us — a CEP
+ // that now exists, an outage, a captive portal. None of those is a
+ // municipality.
+ if resp.StatusCode != http.StatusNotFound {
+ return nil
+ }
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxFaixaBody))
+ if err != nil {
+ return nil
+ }
+ return decodeFaixa(body)
+}
+
+// decodeFaixa reads the `faixa` block of a 404 body. Every failure yields nil:
+// an absent block (the API omits it, never sends null), a body that is not
+// JSON, a block that names no municipality. A field the API grows later is
+// ignored rather than fatal, which is what encoding/json does by default.
+func decodeFaixa(body []byte) *Faixa {
+ if len(body) == 0 {
+ return nil
+ }
+
+ var envelope struct {
+ Faixa *Faixa `json:"faixa"`
+ }
+ if err := json.Unmarshal(body, &envelope); err != nil {
+ return nil
+ }
+
+ faixa := envelope.Faixa
+ if faixa == nil || strings.TrimSpace(faixa.Localidade) == "" {
+ return nil
+ }
+ if faixa.CEPs < 0 {
+ faixa.CEPs = 0
+ }
+ return faixa
+}
diff --git a/internal/lookup/lookup_test.go b/internal/lookup/lookup_test.go
new file mode 100644
index 0000000..6eb7b4e
--- /dev/null
+++ b/internal/lookup/lookup_test.go
@@ -0,0 +1,279 @@
+package lookup
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ cepis "cep.is/go"
+)
+
+const areal404 = `{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE",
+ "faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro","regiao":"Sudeste",
+ "ibge":"3300225","cep_ini":"25845001","cep_fim":"25849899","ceps":118}}`
+
+const plain404 = `{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}`
+
+const address200 = `{"cep":"01001000","logradouro":"Praça da Sé","bairro":"Sé",
+ "localidade":"São Paulo","uf":"SP","estado":"São Paulo","regiao":"Sudeste","ddd":"11"}`
+
+// fakeAPI answers every request with the same status and body, and counts how
+// many arrived: the count is what proves the extra request happens only on the
+// 404 path.
+type fakeAPI struct {
+ server *httptest.Server
+ requests atomic.Int64
+
+ // delay is applied from the delayFrom-th request on, which is how the
+ // second request — the one that reads the faixa — is made slow without
+ // slowing the lookup that precedes it.
+ delay time.Duration
+ delayFrom int64
+}
+
+func newFakeAPI(t *testing.T, status int, body string) *fakeAPI {
+ t.Helper()
+ f := &fakeAPI{delayFrom: 1}
+ f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ n := f.requests.Add(1)
+ if f.delay > 0 && n >= f.delayFrom {
+ select {
+ case <-time.After(f.delay):
+ case <-r.Context().Done():
+ return
+ }
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ w.Write([]byte(body))
+ }))
+ t.Cleanup(f.server.Close)
+ return f
+}
+
+func newClient(t *testing.T, f *fakeAPI, opts ...Option) *Client {
+ t.Helper()
+ api, err := cepis.New(
+ cepis.WithBaseURL(f.server.URL),
+ cepis.WithMaxAttempts(1),
+ cepis.WithTimeout(2*time.Second),
+ )
+ if err != nil {
+ t.Fatalf("cepis.New: %v", err)
+ }
+ return New(api, opts...)
+}
+
+func TestNotFoundCarriesTheMunicipality(t *testing.T) {
+ f := newFakeAPI(t, http.StatusNotFound, areal404)
+ _, err := newClient(t, f).Lookup(context.Background(), "25845-000")
+
+ if !errors.Is(err, cepis.ErrCEPNotFound) {
+ t.Fatalf("err = %v, want a not-found error", err)
+ }
+ faixa := FaixaOf(err)
+ if faixa == nil {
+ t.Fatal("the faixa on the 404 was dropped")
+ }
+ if faixa.Place() != "Areal/RJ" {
+ t.Fatalf("place = %q, want Areal/RJ", faixa.Place())
+ }
+ if faixa.CEPs != 118 {
+ t.Fatalf("ceps = %d, want 118", faixa.CEPs)
+ }
+ if faixa.CEPIni != "25845001" || faixa.CEPFim != "25849899" {
+ t.Fatalf("range = %s..%s, want 25845001..25849899", faixa.CEPIni, faixa.CEPFim)
+ }
+ if faixa.IBGE != "3300225" || faixa.Estado != "Rio de Janeiro" || faixa.Regiao != "Sudeste" {
+ t.Fatalf("faixa lost fields: %+v", faixa)
+ }
+}
+
+func TestNotFoundWithoutFaixaIsUntouched(t *testing.T) {
+ f := newFakeAPI(t, http.StatusNotFound, plain404)
+ _, err := newClient(t, f).Lookup(context.Background(), "99999999")
+
+ if !errors.Is(err, cepis.ErrCEPNotFound) {
+ t.Fatalf("err = %v, want a not-found error", err)
+ }
+ if faixa := FaixaOf(err); faixa != nil {
+ t.Fatalf("invented a municipality: %+v", faixa)
+ }
+ var notFound *NotFoundError
+ if errors.As(err, &notFound) {
+ t.Fatal("a 404 without a faixa should come back as the library's own error")
+ }
+}
+
+// A faixa the bot cannot trust must cost nothing: the reply falls back to the
+// plain one, and never to a panic or an unanswered message.
+func TestMalformedFaixaFallsBack(t *testing.T) {
+ bodies := map[string]string{
+ "empty body": ``,
+ "not JSON": `<html>captive portal</html>`,
+ "truncated": `{"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal"`,
+ "faixa is null": `{"erro":"cep_nao_encontrado","faixa":null}`,
+ "faixa is a string": `{"erro":"cep_nao_encontrado","faixa":"Areal/RJ"}`,
+ "faixa is an array": `{"erro":"cep_nao_encontrado","faixa":[{"localidade":"Areal"}]}`,
+ "no localidade": `{"faixa":{"uf":"RJ","cep_ini":"25845001","cep_fim":"25849899"}}`,
+ "blank localidade": `{"faixa":{"localidade":" ","uf":"RJ"}}`,
+ "ceps is a string": `{"faixa":{"localidade":"Areal","uf":"RJ","ceps":"118"}}`,
+ "whole body is null": `null`,
+ "whole body is array": `[]`,
+ }
+
+ for name, body := range bodies {
+ t.Run(name, func(t *testing.T) {
+ f := newFakeAPI(t, http.StatusNotFound, body)
+ _, err := newClient(t, f).Lookup(context.Background(), "25845000")
+ if !errors.Is(err, cepis.ErrCEPNotFound) {
+ t.Fatalf("err = %v, want a not-found error", err)
+ }
+ if faixa := FaixaOf(err); faixa != nil {
+ t.Fatalf("trusted a broken faixa: %+v", faixa)
+ }
+ })
+ }
+}
+
+// A negative count is nonsense the formatter would print verbatim, so it is
+// flattened to "unknown" while the municipality itself is kept.
+func TestNegativeCountIsDropped(t *testing.T) {
+ f := newFakeAPI(t, http.StatusNotFound, `{"faixa":{"localidade":"Areal","uf":"RJ","ceps":-3}}`)
+ _, err := newClient(t, f).Lookup(context.Background(), "25845000")
+
+ faixa := FaixaOf(err)
+ if faixa == nil {
+ t.Fatal("the municipality should have survived a bad count")
+ }
+ if faixa.CEPs != 0 {
+ t.Fatalf("ceps = %d, want 0", faixa.CEPs)
+ }
+}
+
+func TestSuccessCostsOneRequest(t *testing.T) {
+ f := newFakeAPI(t, http.StatusOK, address200)
+ address, err := newClient(t, f).Lookup(context.Background(), "01001000")
+
+ if err != nil {
+ t.Fatalf("Lookup: %v", err)
+ }
+ if address.Localidade != "São Paulo" {
+ t.Fatalf("localidade = %q", address.Localidade)
+ }
+ if got := f.requests.Load(); got != 1 {
+ t.Fatalf("the API saw %d requests for a hit, want 1", got)
+ }
+}
+
+func TestOtherFailuresAskOnlyOnce(t *testing.T) {
+ f := newFakeAPI(t, http.StatusInternalServerError, `{"erro":"interno"}`)
+ _, err := newClient(t, f).Lookup(context.Background(), "50000000")
+
+ if !errors.Is(err, cepis.ErrServer) {
+ t.Fatalf("err = %v, want a server error", err)
+ }
+ if got := f.requests.Load(); got != 1 {
+ t.Fatalf("the API saw %d requests for a 500, want 1", got)
+ }
+}
+
+// The faixa is worth one short wait and no more. A slow API still gets an
+// answer out to the user, without the municipality.
+func TestSlowFaixaIsAbandoned(t *testing.T) {
+ f := newFakeAPI(t, http.StatusNotFound, areal404)
+ client := newClient(t, f, WithTimeout(20*time.Millisecond))
+ f.delay, f.delayFrom = 500*time.Millisecond, 2
+
+ started := time.Now()
+ _, err := client.Lookup(context.Background(), "25845000")
+ elapsed := time.Since(started)
+
+ if !errors.Is(err, cepis.ErrCEPNotFound) {
+ t.Fatalf("err = %v, want a not-found error", err)
+ }
+ if faixa := FaixaOf(err); faixa != nil {
+ t.Fatalf("used a faixa that arrived too late: %+v", faixa)
+ }
+ if elapsed > 400*time.Millisecond {
+ t.Fatalf("waited %v for the faixa, want it abandoned quickly", elapsed)
+ }
+}
+
+// A CEP that stopped being a 404 between the two requests is not a
+// municipality: the second answer is ignored rather than mixed into the first.
+func TestFaixaIgnoresANonNotFoundSecondAnswer(t *testing.T) {
+ var count atomic.Int64
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ if count.Add(1) == 1 {
+ w.WriteHeader(http.StatusNotFound)
+ w.Write([]byte(plain404))
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(address200))
+ }))
+ t.Cleanup(server.Close)
+
+ api, err := cepis.New(cepis.WithBaseURL(server.URL), cepis.WithMaxAttempts(1))
+ if err != nil {
+ t.Fatalf("cepis.New: %v", err)
+ }
+
+ _, err = New(api).Lookup(context.Background(), "25845000")
+ if !errors.Is(err, cepis.ErrCEPNotFound) {
+ t.Fatalf("err = %v, want a not-found error", err)
+ }
+ if faixa := FaixaOf(err); faixa != nil {
+ t.Fatalf("read a municipality out of a 200: %+v", faixa)
+ }
+}
+
+func TestFaixaOfAcceptsAnything(t *testing.T) {
+ for name, err := range map[string]error{
+ "nil": nil,
+ "unrelated": errors.New("boom"),
+ "not found": cepis.ErrCEPNotFound,
+ "no faixa": &NotFoundError{Err: cepis.ErrCEPNotFound},
+ "wrapped": &NotFoundError{Faixa: &Faixa{Localidade: "Areal", UF: "RJ"}, Err: cepis.ErrCEPNotFound},
+ "empty type": &NotFoundError{},
+ } {
+ if name == "wrapped" {
+ if got := FaixaOf(err); got == nil || got.Place() != "Areal/RJ" {
+ t.Fatalf("%s: FaixaOf = %+v", name, got)
+ }
+ continue
+ }
+ if got := FaixaOf(err); got != nil {
+ t.Fatalf("%s: FaixaOf = %+v, want nil", name, got)
+ }
+ }
+}
+
+// The zero value still matches the sentinel, so no construction of this type
+// can ever produce an error the bot fails to recognise as a not-found.
+func TestEmptyNotFoundErrorStillMatches(t *testing.T) {
+ var err error = &NotFoundError{}
+ if !errors.Is(err, cepis.ErrCEPNotFound) {
+ t.Fatal("an empty NotFoundError should still be a not-found")
+ }
+ if err.Error() == "" {
+ t.Fatal("an empty NotFoundError should still say something")
+ }
+}
+
+func TestPlaceWithoutUF(t *testing.T) {
+ faixa := &Faixa{Localidade: "Areal"}
+ if faixa.Place() != "Areal" {
+ t.Fatalf("place = %q, want Areal", faixa.Place())
+ }
+ var missing *Faixa
+ if missing.Place() != "" {
+ t.Fatalf("place = %q, want empty", missing.Place())
+ }
+}
diff --git a/internal/telegram/client.go b/internal/telegram/client.go
index 1b9be78..8d935e0 100644
--- a/internal/telegram/client.go
+++ b/internal/telegram/client.go
@@ -24,7 +24,7 @@ import (
// DefaultBaseURL is the public Bot API host.
const DefaultBaseURL = "https://api.telegram.org"
-const userAgent = "cepis-telegram/0.1.0 (+https://cep.is)"
+const userAgent = "cepis-telegram/0.2.0 (+https://cep.is)"
// Error is a failure reported by the Bot API itself, as opposed to a transport
// failure. Code is the Bot API error_code, which mirrors an HTTP status.