summaryrefslogtreecommitdiffstats
path: root/internal/bot/format.go
blob: 86a0ac940856401d93ddf10ede31f53fc9478651 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
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 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>."
	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."
}

// 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 := 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(place(faixa)) + "</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()
}

// faixaOf returns the municipality the API named on a 404, or nil when it named
// none. Any error is safe to pass, including nil: only a *cepis.Error carrying
// a faixa yields one.
func faixaOf(err error) *cepis.Faixa {
	var cerr *cepis.Error
	if errors.As(err, &cerr) {
		return cerr.Faixa
	}
	return nil
}

// place is the municipality as it is written to a user: "Areal/RJ", or just
// "Areal" when the API sent no UF.
func place(faixa *cepis.Faixa) string {
	if faixa == nil {
		return ""
	}
	if faixa.UF == "" {
		return faixa.Localidade
	}
	return faixa.Localidade + "/" + faixa.UF
}

// 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 *cepis.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 *cepis.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 {
	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) }