summaryrefslogtreecommitdiffstats
path: root/internal/bot
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
downloadtelegram-0.1.0.tar.gz
Initial importv0.1.0
Diffstat (limited to 'internal/bot')
-rw-r--r--internal/bot/bot.go288
-rw-r--r--internal/bot/command.go54
-rw-r--r--internal/bot/command_test.go59
-rw-r--r--internal/bot/format.go225
-rw-r--r--internal/bot/format_test.go126
-rw-r--r--internal/bot/ratelimit.go139
-rw-r--r--internal/bot/ratelimit_test.go128
7 files changed, 1019 insertions, 0 deletions
diff --git a/internal/bot/bot.go b/internal/bot/bot.go
new file mode 100644
index 0000000..b29dc36
--- /dev/null
+++ b/internal/bot/bot.go
@@ -0,0 +1,288 @@
+// Package bot turns a Telegram update into an answer about a CEP.
+//
+// It knows nothing about how the update arrived. [Bot.Handle] satisfies
+// telegram.Handler, which the long-polling loop drives today and which a
+// webhook receiver could drive tomorrow without touching anything here.
+package bot
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "strings"
+
+ cepis "cep.is/go"
+
+ "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.
+type Lookups interface {
+ Lookup(ctx context.Context, cep string) (*cepis.Address, error)
+}
+
+// Messenger is the subset of the Bot API this bot calls to answer.
+type Messenger interface {
+ SendMessage(ctx context.Context, req telegram.SendMessageRequest) error
+ AnswerInlineQuery(ctx context.Context, req telegram.AnswerInlineQueryRequest) error
+}
+
+// Cache times for inline answers, in seconds. A found address is stable for a
+// day — the DNE database changes twice a month — so Telegram may serve it from
+// its own cache and spare both this bot and the API. Anything else is not
+// cached, so a transient failure is not pinned to a query for hours.
+const (
+ inlineCacheFound = 86400
+ inlineCacheOther = 0
+)
+
+// Bot answers CEP lookups.
+type Bot struct {
+ lookups Lookups
+ messenger Messenger
+ limiter *Limiter
+ username string
+ logger *slog.Logger
+}
+
+// Option configures a [Bot].
+type Option func(*Bot)
+
+// WithLimiter replaces the per-user token bucket.
+func WithLimiter(limiter *Limiter) Option {
+ return func(b *Bot) {
+ if limiter != nil {
+ b.limiter = limiter
+ }
+ }
+}
+
+// WithLogger sets the logger.
+func WithLogger(logger *slog.Logger) Option {
+ return func(b *Bot) {
+ if logger != nil {
+ b.logger = logger
+ }
+ }
+}
+
+// New builds a bot. The username is the bot's own, as reported by getMe, and is
+// what makes "/cep@username" and the inline examples in the help text correct.
+func New(lookups Lookups, messenger Messenger, username string, opts ...Option) *Bot {
+ b := &Bot{
+ lookups: lookups,
+ messenger: messenger,
+ limiter: NewLimiter(),
+ username: username,
+ logger: slog.Default(),
+ }
+ for _, opt := range opts {
+ opt(b)
+ }
+ return b
+}
+
+// Handle dispatches one update. It implements telegram.Handler.
+func (b *Bot) Handle(ctx context.Context, update telegram.Update) error {
+ switch {
+ case update.InlineQuery != nil:
+ return b.handleInline(ctx, update.InlineQuery)
+ case update.Message != nil:
+ return b.handleMessage(ctx, update.Message)
+ case update.ChannelPost != nil:
+ return b.handleMessage(ctx, update.ChannelPost)
+ }
+ return nil
+}
+
+// handleMessage covers both messages and channel posts. They have the same
+// shape, and the only difference that matters is that a channel post has no
+// author, which the rate-limit key already accounts for.
+func (b *Bot) handleMessage(ctx context.Context, message *telegram.Message) error {
+ if message.From != nil && message.From.IsBot {
+ return nil
+ }
+
+ command, isCommand := ParseCommand(message.Text)
+ if isCommand {
+ if !command.AddressedTo(b.username) {
+ return nil
+ }
+ return b.handleCommand(ctx, message, command)
+ }
+
+ if !message.Chat.IsPrivate() {
+ return nil
+ }
+
+ text := strings.TrimSpace(message.Text)
+ if text == "" {
+ return nil
+ }
+ if _, err := cepis.NormalizeCEP(text); err != nil {
+ return b.reply(ctx, message, UnknownMessage)
+ }
+ return b.lookupAndReply(ctx, message, text)
+}
+
+func (b *Bot) handleCommand(ctx context.Context, message *telegram.Message, command Command) error {
+ switch command.Name {
+ case "start":
+ return b.reply(ctx, message, StartMessage(b.username))
+ case "help":
+ return b.reply(ctx, message, HelpMessage(b.username))
+ case "cep":
+ if command.Argument == "" {
+ return b.reply(ctx, message, UsageMessage(b.username))
+ }
+ return b.lookupAndReply(ctx, message, command.Argument)
+ }
+
+ if message.Chat.IsPrivate() {
+ return b.reply(ctx, message, UnknownMessage)
+ }
+ return nil
+}
+
+func (b *Bot) lookupAndReply(ctx context.Context, message *telegram.Message, raw string) error {
+ decision := b.limiter.Allow(rateKey(message))
+ if !decision.Allowed {
+ b.logger.Debug("rate limited", slog.Int64("key", rateKey(message)))
+ if decision.Warn {
+ return b.reply(ctx, message, RateLimitMessage(decision.RetryAfter))
+ }
+ return nil
+ }
+
+ address, err := b.lookups.Lookup(ctx, raw)
+ if err != nil {
+ b.logger.Info("lookup failed",
+ slog.String("chat_type", message.Chat.Type),
+ slog.String("error", err.Error()))
+ return b.reply(ctx, message, ErrorMessage(err))
+ }
+ return b.reply(ctx, message, FormatAddress(address))
+}
+
+func (b *Bot) reply(ctx context.Context, message *telegram.Message, text string) error {
+ req := telegram.SendMessageRequest{
+ ChatID: message.Chat.ID,
+ Text: text,
+ ParseMode: ParseMode,
+ LinkPreviewOptions: &telegram.LinkPreviewOptions{IsDisabled: true},
+ }
+ if !message.Chat.IsPrivate() {
+ req.ReplyParameters = &telegram.ReplyParameters{
+ MessageID: message.MessageID,
+ AllowSendingWithoutReply: true,
+ }
+ }
+ return b.messenger.SendMessage(ctx, req)
+}
+
+// handleInline answers an inline query.
+//
+// Telegram fires one query per keystroke, so a partial CEP must cost nothing: a
+// query that is not eight digits is answered with an empty list, without
+// touching the API and without spending a rate-limit token. Only a complete CEP
+// becomes a lookup.
+func (b *Bot) handleInline(ctx context.Context, query *telegram.InlineQuery) error {
+ text := strings.TrimSpace(query.Query)
+ if text == "" {
+ return b.answerInline(ctx, query, nil, inlineCacheOther)
+ }
+
+ if _, err := cepis.NormalizeCEP(text); err != nil {
+ if looksPartial(text) {
+ return b.answerInline(ctx, query, nil, inlineCacheOther)
+ }
+ return b.answerInline(ctx, query, []telegram.InlineQueryResult{
+ b.inlineNotice(query, "CEP inválido", "Use oito dígitos: 01001000", ErrorMessage(err)),
+ }, inlineCacheOther)
+ }
+
+ decision := b.limiter.Allow(query.From.ID)
+ if !decision.Allowed {
+ return b.answerInline(ctx, query, nil, inlineCacheOther)
+ }
+
+ address, err := b.lookups.Lookup(ctx, text)
+ 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)),
+ }, inlineCacheOther)
+ }
+
+ return b.answerInline(ctx, query, []telegram.InlineQueryResult{{
+ Type: "article",
+ ID: address.CEP,
+ Title: InlineTitle(address),
+ Description: InlineDescription(address),
+ InputMessageContent: telegram.InputMessageContent{
+ MessageText: FormatAddress(address),
+ ParseMode: ParseMode,
+ LinkPreviewOptions: &telegram.LinkPreviewOptions{IsDisabled: true},
+ },
+ }}, inlineCacheFound)
+}
+
+func (b *Bot) answerInline(ctx context.Context, query *telegram.InlineQuery, results []telegram.InlineQueryResult, cacheTime int) error {
+ if results == nil {
+ results = []telegram.InlineQueryResult{}
+ }
+ return b.messenger.AnswerInlineQuery(ctx, telegram.AnswerInlineQueryRequest{
+ InlineQueryID: query.ID,
+ Results: results,
+ CacheTime: cacheTime,
+ })
+}
+
+func (b *Bot) inlineNotice(query *telegram.InlineQuery, title, description, text string) telegram.InlineQueryResult {
+ return telegram.InlineQueryResult{
+ Type: "article",
+ ID: "notice-" + query.ID,
+ Title: title,
+ Description: description,
+ InputMessageContent: telegram.InputMessageContent{
+ MessageText: text,
+ ParseMode: ParseMode,
+ LinkPreviewOptions: &telegram.LinkPreviewOptions{IsDisabled: true},
+ },
+ }
+}
+
+func inlineErrorTitle(err error) string {
+ if isNotFound(err) {
+ return "CEP não encontrado"
+ }
+ return "Não consegui consultar agora"
+}
+
+// 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.
+func rateKey(message *telegram.Message) int64 {
+ if message.From != nil {
+ return message.From.ID
+ }
+ return message.Chat.ID
+}
+
+// looksPartial reports whether an inline query is plausibly a CEP still being
+// typed, in which case answering with nothing is better than accusing the user
+// of a mistake at every keystroke.
+func looksPartial(text string) bool {
+ if len(text) > 9 {
+ return false
+ }
+ for _, r := range text {
+ if (r < '0' || r > '9') && r != '-' && r != '.' {
+ return false
+ }
+ }
+ return true
+}
+
+func isNotFound(err error) bool { return errors.Is(err, cepis.ErrCEPNotFound) }
diff --git a/internal/bot/command.go b/internal/bot/command.go
new file mode 100644
index 0000000..9e13060
--- /dev/null
+++ b/internal/bot/command.go
@@ -0,0 +1,54 @@
+package bot
+
+import "strings"
+
+// Command is a parsed "/name@target argument" line.
+type Command struct {
+ // Name is the command without the leading slash, lowercased.
+ Name string
+
+ // Target is the bot username the command was addressed to, without the
+ // "@", or "" when the user did not address anyone.
+ Target string
+
+ // Argument is everything after the command, trimmed.
+ Argument string
+}
+
+// ParseCommand splits a message that starts with a slash.
+//
+// The "@username" suffix is not optional to handle. Telegram appends it
+// automatically whenever more than one bot is in a chat, so in exactly the busy
+// groups where the bot is most visible, every command arrives as
+// "/cep@thebotname 01001000". A bot that only matches the bare form looks broken
+// there.
+//
+// ok is false when the text is not a command at all.
+func ParseCommand(text string) (Command, bool) {
+ trimmed := strings.TrimSpace(text)
+ if !strings.HasPrefix(trimmed, "/") || len(trimmed) == 1 {
+ return Command{}, false
+ }
+
+ head, argument, _ := strings.Cut(trimmed[1:], " ")
+ name, target, _ := strings.Cut(head, "@")
+ if name == "" {
+ return Command{}, false
+ }
+
+ return Command{
+ Name: strings.ToLower(name),
+ Target: target,
+ Argument: strings.TrimSpace(argument),
+ }, true
+}
+
+// AddressedTo reports whether the command is for the bot with this username.
+//
+// An unaddressed command counts as addressed: in a private chat nobody types
+// the suffix, and in a group with a single bot Telegram does not add it either.
+// A command addressed to some other bot is not ours, and answering it would put
+// two replies under one message.
+func (c Command) AddressedTo(username string) bool {
+ return c.Target == "" || strings.EqualFold(c.Target, username)
+}
diff --git a/internal/bot/command_test.go b/internal/bot/command_test.go
new file mode 100644
index 0000000..fe1c895
--- /dev/null
+++ b/internal/bot/command_test.go
@@ -0,0 +1,59 @@
+package bot
+
+import "testing"
+
+func TestParseCommand(t *testing.T) {
+ cases := []struct {
+ text string
+ ok bool
+ name string
+ target string
+ argument string
+ }{
+ {text: "/cep 01001000", ok: true, name: "cep", argument: "01001000"},
+ {text: "/cep@cepisbot 01001-000", ok: true, name: "cep", target: "cepisbot", argument: "01001-000"},
+ {text: " /CEP@CepisBot 01001000 ", ok: true, name: "cep", target: "CepisBot", argument: "01001000"},
+ {text: "/start", ok: true, name: "start"},
+ {text: "/help@cepisbot", ok: true, name: "help", target: "cepisbot"},
+ {text: "01001000", ok: false},
+ {text: "", ok: false},
+ {text: "/", ok: false},
+ {text: "não é comando /cep", ok: false},
+ }
+
+ for _, tc := range cases {
+ command, ok := ParseCommand(tc.text)
+ if ok != tc.ok {
+ t.Fatalf("ParseCommand(%q) ok = %v, want %v", tc.text, ok, tc.ok)
+ }
+ if !ok {
+ continue
+ }
+ if command.Name != tc.name || command.Target != tc.target || command.Argument != tc.argument {
+ t.Fatalf("ParseCommand(%q) = %+v, want {%q %q %q}",
+ tc.text, command, tc.name, tc.target, tc.argument)
+ }
+ }
+}
+
+func TestAddressedTo(t *testing.T) {
+ cases := []struct {
+ text string
+ want bool
+ }{
+ {"/cep 01001000", true},
+ {"/cep@cepisbot 01001000", true},
+ {"/cep@CEPISBOT 01001000", true},
+ {"/cep@outrobot 01001000", false},
+ }
+
+ for _, tc := range cases {
+ command, ok := ParseCommand(tc.text)
+ if !ok {
+ t.Fatalf("ParseCommand(%q) did not parse", tc.text)
+ }
+ if got := command.AddressedTo("cepisbot"); got != tc.want {
+ t.Fatalf("AddressedTo(%q) = %v, want %v", tc.text, got, tc.want)
+ }
+ }
+}
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) }
diff --git a/internal/bot/format_test.go b/internal/bot/format_test.go
new file mode 100644
index 0000000..56b8dda
--- /dev/null
+++ b/internal/bot/format_test.go
@@ -0,0 +1,126 @@
+package bot
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+
+ cepis "cep.is/go"
+)
+
+func decodeAddress(t *testing.T, body string) *cepis.Address {
+ t.Helper()
+ var address cepis.Address
+ if err := json.Unmarshal([]byte(body), &address); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ return &address
+}
+
+const streetJSON = `{"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar",
+ "unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP","estado":"São Paulo",
+ "regiao":"Sudeste","ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107",
+ "geo":{"lat":-23.567202,"lon":-46.649659,"raio_m":128,"precisao":"logradouro"}}`
+
+const municipalityJSON = `{"cep":"76200000","logradouro":"","complemento":"","unidade":"",
+ "bairro":"","localidade":"Iporá","uf":"GO","estado":"Goiás","regiao":"Centro-Oeste",
+ "ibge":"5210000","gia":"","ddd":"64","siafi":"9439",
+ "geo":{"lat":-16.441,"lon":-51.118,"raio_m":18400,"precisao":"localidade"}}`
+
+func TestFormatStreetLevelCEP(t *testing.T) {
+ text := FormatAddress(decodeAddress(t, streetJSON))
+
+ for _, needed := range []string{"01001-000", "Praça da Sé", "lado ímpar", "Sé — São Paulo/SP", "DDD 11", "128 m"} {
+ if !strings.Contains(text, needed) {
+ t.Fatalf("reply is missing %q:\n%s", needed, text)
+ }
+ }
+ if !strings.Contains(text, "openstreetmap.org/?mlat=-23.567202&amp;mlon=-46.649659") {
+ t.Fatalf("a street-level CEP should link to the point:\n%s", text)
+ }
+}
+
+func TestFormatMunicipalityCEP(t *testing.T) {
+ text := FormatAddress(decodeAddress(t, municipalityJSON))
+
+ if strings.Contains(text, "openstreetmap.org") {
+ t.Fatalf("a municipality CEP must not offer a pin:\n%s", text)
+ }
+ for _, needed := range []string{"CEP geral de município", "centroide de Iporá", "18,4 km", "não serve", "Iporá/GO"} {
+ if !strings.Contains(strings.ToLower(text), strings.ToLower(needed)) {
+ t.Fatalf("reply is missing %q:\n%s", needed, text)
+ }
+ }
+}
+
+func TestFormatDiffersByPrecision(t *testing.T) {
+ street := FormatAddress(decodeAddress(t, streetJSON))
+ municipality := FormatAddress(decodeAddress(t, municipalityJSON))
+ if street == municipality {
+ t.Fatal("the two precisions produced the same reply")
+ }
+}
+
+func TestFormatWithoutGeo(t *testing.T) {
+ text := FormatAddress(decodeAddress(t, `{"cep":"29902000","logradouro":"Rua X","localidade":"Linhares","uf":"ES"}`))
+ if !strings.Contains(text, "Sem coordenada") {
+ t.Fatalf("reply should say there is no coordinate:\n%s", text)
+ }
+ if strings.Contains(text, "openstreetmap.org") {
+ t.Fatalf("no coordinate must mean no link:\n%s", text)
+ }
+}
+
+func TestFormatEscapesHTML(t *testing.T) {
+ text := FormatAddress(decodeAddress(t, `{"cep":"12345678","logradouro":"Rua <b>Fake</b> & Cia","localidade":"X","uf":"SP"}`))
+ if strings.Contains(text, "<b>Fake</b>") {
+ t.Fatalf("address data must be escaped:\n%s", text)
+ }
+ if !strings.Contains(text, "&lt;b&gt;Fake&lt;/b&gt; &amp; Cia") {
+ t.Fatalf("escaping is wrong:\n%s", text)
+ }
+}
+
+func TestInlineDescriptionWarnsAboutMunicipality(t *testing.T) {
+ description := InlineDescription(decodeAddress(t, municipalityJSON))
+ if !strings.Contains(description, "CEP geral do município") {
+ t.Fatalf("description = %q", description)
+ }
+ if street := InlineDescription(decodeAddress(t, streetJSON)); strings.Contains(street, "geral do município") {
+ t.Fatalf("street description = %q", street)
+ }
+}
+
+func TestErrorMessagesAreDistinct(t *testing.T) {
+ cases := map[string]error{
+ "not found": cepis.ErrCEPNotFound,
+ "invalid": cepis.ErrInvalidCEP,
+ "rate limited": cepis.ErrRateLimited,
+ "server": cepis.ErrServer,
+ "network": cepis.ErrNetwork,
+ "invalid response": cepis.ErrInvalidResponse,
+ "unknown": errors.New("something else"),
+ }
+
+ seen := make(map[string]string, len(cases))
+ for name, err := range cases {
+ message := ErrorMessage(err)
+ if message == "" {
+ t.Fatalf("%s produced no message", name)
+ }
+ if other, clash := seen[message]; clash {
+ t.Fatalf("%s and %s share a message: %q", name, other, message)
+ }
+ seen[message] = name
+ }
+}
+
+func TestHelpMentionsEveryEntryPoint(t *testing.T) {
+ text := HelpMessage("cepisbot")
+ for _, needed := range []string{"01001-000", "/cep 01001000", "@cepisbot 01001000", "https://cep.is"} {
+ if !strings.Contains(text, needed) {
+ t.Fatalf("help is missing %q:\n%s", needed, text)
+ }
+ }
+}
diff --git a/internal/bot/ratelimit.go b/internal/bot/ratelimit.go
new file mode 100644
index 0000000..b5333fd
--- /dev/null
+++ b/internal/bot/ratelimit.go
@@ -0,0 +1,139 @@
+package bot
+
+import (
+ "sync"
+ "time"
+)
+
+// The public bot answers anyone, so one person with a script can spend the
+// whole service's budget against the cep.is API. These constants bound what a
+// single Telegram account may cost.
+//
+// Burst is what a human actually does: paste a handful of CEPs in a row, then
+// think. Refill is the sustained rate afterwards — 20 lookups a minute, which
+// no person reaches by hand and which a loop hits within a second.
+//
+// WarnInterval keeps the refusal from becoming its own flood: someone held at
+// the limit is told once, then ignored until the window passes, so a script
+// hammering the bot does not turn into the bot hammering Telegram.
+const (
+ Burst = 5
+ Refill = 3 * time.Second
+ WarnInterval = 30 * time.Second
+)
+
+// The bucket table is swept rather than kept forever: every distinct user gets
+// an entry, and a public bot meets a lot of users. An entry is dropped once it
+// has been idle long enough that it would have refilled to full anyway, at
+// which point forgetting it and recreating it are the same thing.
+const (
+ bucketIdleTTL = 15 * time.Minute
+ sweepEvery = 5 * time.Minute
+)
+
+// Decision is the answer of [Limiter.Allow].
+type Decision struct {
+ // Allowed reports whether the request may proceed.
+ Allowed bool
+
+ // Warn reports whether the user should be told they are being limited.
+ // It is true at most once per [WarnInterval] per key.
+ Warn bool
+
+ // RetryAfter is roughly how long until the next token, meant for the
+ // message shown to the user.
+ RetryAfter time.Duration
+}
+
+type bucket struct {
+ tokens float64
+ lastSeen time.Time
+ lastWarn time.Time
+}
+
+// Limiter is a token bucket per key, where the key is the Telegram user id — or
+// the chat id for channel posts, which carry no author.
+//
+// It is in-memory and per-process on purpose: the bot runs as a single
+// container, and a shared store would add a dependency and a failure mode to
+// protect against something a single process can already see.
+type Limiter struct {
+ burst float64
+ refill time.Duration
+ warnInterval time.Duration
+ now func() time.Time
+
+ mu sync.Mutex
+ buckets map[int64]*bucket
+ lastSweep time.Time
+}
+
+// NewLimiter builds a limiter with the package constants.
+func NewLimiter() *Limiter {
+ return NewLimiterWith(Burst, Refill, WarnInterval, time.Now)
+}
+
+// NewLimiterWith builds a limiter with explicit parameters and an injectable
+// clock, which is how the test suite exercises refilling without waiting for it.
+func NewLimiterWith(burst int, refill, warnInterval time.Duration, now func() time.Time) *Limiter {
+ if now == nil {
+ now = time.Now
+ }
+ return &Limiter{
+ burst: float64(burst),
+ refill: refill,
+ warnInterval: warnInterval,
+ now: now,
+ buckets: make(map[int64]*bucket),
+ }
+}
+
+// Allow takes one token for key and reports what the caller should do.
+func (l *Limiter) Allow(key int64) Decision {
+ now := l.now()
+
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ l.sweep(now)
+
+ b, ok := l.buckets[key]
+ if !ok {
+ b = &bucket{tokens: l.burst}
+ l.buckets[key] = b
+ } else if l.refill > 0 {
+ b.tokens += float64(now.Sub(b.lastSeen)) / float64(l.refill)
+ if b.tokens > l.burst {
+ b.tokens = l.burst
+ }
+ }
+ b.lastSeen = now
+
+ if b.tokens >= 1 {
+ b.tokens--
+ return Decision{Allowed: true}
+ }
+
+ decision := Decision{RetryAfter: time.Duration((1 - b.tokens) * float64(l.refill))}
+ if b.lastWarn.IsZero() || now.Sub(b.lastWarn) >= l.warnInterval {
+ b.lastWarn = now
+ decision.Warn = true
+ }
+ return decision
+}
+
+func (l *Limiter) sweep(now time.Time) {
+ if l.lastSweep.IsZero() {
+ l.lastSweep = now
+ return
+ }
+ if now.Sub(l.lastSweep) < sweepEvery {
+ return
+ }
+ l.lastSweep = now
+ for key, b := range l.buckets {
+ if now.Sub(b.lastSeen) >= bucketIdleTTL {
+ delete(l.buckets, key)
+ }
+ }
+}
diff --git a/internal/bot/ratelimit_test.go b/internal/bot/ratelimit_test.go
new file mode 100644
index 0000000..7f09787
--- /dev/null
+++ b/internal/bot/ratelimit_test.go
@@ -0,0 +1,128 @@
+package bot
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+type fakeClock struct {
+ mu sync.Mutex
+ now time.Time
+}
+
+func (c *fakeClock) Now() time.Time {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.now
+}
+
+func (c *fakeClock) Advance(d time.Duration) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.now = c.now.Add(d)
+}
+
+func TestLimiterAllowsTheBurstThenStops(t *testing.T) {
+ clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
+ limiter := NewLimiterWith(3, time.Second, 30*time.Second, clock.Now)
+
+ for i := 0; i < 3; i++ {
+ if decision := limiter.Allow(1); !decision.Allowed {
+ t.Fatalf("request %d was refused inside the burst", i+1)
+ }
+ }
+ decision := limiter.Allow(1)
+ if decision.Allowed {
+ t.Fatal("the fourth request should be refused")
+ }
+ if !decision.Warn {
+ t.Fatal("the first refusal should warn the user")
+ }
+ if decision.RetryAfter <= 0 {
+ t.Fatalf("retry after = %v, want a positive hint", decision.RetryAfter)
+ }
+}
+
+func TestLimiterWarnsOncePerWindow(t *testing.T) {
+ clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
+ limiter := NewLimiterWith(1, time.Minute, 30*time.Second, clock.Now)
+
+ limiter.Allow(1)
+ if !limiter.Allow(1).Warn {
+ t.Fatal("the first refusal should warn")
+ }
+ if limiter.Allow(1).Warn {
+ t.Fatal("a second refusal inside the window should stay silent")
+ }
+
+ clock.Advance(31 * time.Second)
+ if !limiter.Allow(1).Warn {
+ t.Fatal("a refusal after the window should warn again")
+ }
+}
+
+func TestLimiterRefills(t *testing.T) {
+ clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
+ limiter := NewLimiterWith(2, time.Second, time.Minute, clock.Now)
+
+ limiter.Allow(1)
+ limiter.Allow(1)
+ if limiter.Allow(1).Allowed {
+ t.Fatal("the bucket should be empty")
+ }
+
+ clock.Advance(time.Second)
+ if !limiter.Allow(1).Allowed {
+ t.Fatal("one second should have bought one token")
+ }
+ if limiter.Allow(1).Allowed {
+ t.Fatal("only one token should have been refilled")
+ }
+}
+
+func TestLimiterNeverExceedsTheBurst(t *testing.T) {
+ clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
+ limiter := NewLimiterWith(2, time.Second, time.Minute, clock.Now)
+
+ limiter.Allow(1)
+ clock.Advance(time.Hour)
+
+ if !limiter.Allow(1).Allowed || !limiter.Allow(1).Allowed {
+ t.Fatal("the bucket should be full again")
+ }
+ if limiter.Allow(1).Allowed {
+ t.Fatal("an idle hour must not bank more than the burst")
+ }
+}
+
+func TestLimiterKeysAreIndependent(t *testing.T) {
+ clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
+ limiter := NewLimiterWith(1, time.Minute, time.Minute, clock.Now)
+
+ if !limiter.Allow(1).Allowed {
+ t.Fatal("first user refused")
+ }
+ if limiter.Allow(1).Allowed {
+ t.Fatal("first user should now be limited")
+ }
+ if !limiter.Allow(2).Allowed {
+ t.Fatal("one user's flood must not silence another")
+ }
+}
+
+func TestLimiterIsConcurrencySafe(t *testing.T) {
+ limiter := NewLimiterWith(100, time.Millisecond, time.Second, nil)
+
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func(key int64) {
+ defer wg.Done()
+ for j := 0; j < 20; j++ {
+ limiter.Allow(key % 5)
+ }
+ }(int64(i))
+ }
+ wg.Wait()
+}