diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 15:18:21 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 15:18:21 -0300 |
| commit | 3ca1d2c4370e8e79aba3940d5c8fe115960b75a8 (patch) | |
| tree | aa964675c588b80e4f81182d34130ba2d7d984e1 /internal/bot | |
| download | telegram-3ca1d2c4370e8e79aba3940d5c8fe115960b75a8.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'internal/bot')
| -rw-r--r-- | internal/bot/bot.go | 288 | ||||
| -rw-r--r-- | internal/bot/command.go | 54 | ||||
| -rw-r--r-- | internal/bot/command_test.go | 59 | ||||
| -rw-r--r-- | internal/bot/format.go | 225 | ||||
| -rw-r--r-- | internal/bot/format_test.go | 126 | ||||
| -rw-r--r-- | internal/bot/ratelimit.go | 139 | ||||
| -rw-r--r-- | internal/bot/ratelimit_test.go | 128 |
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&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, "<b>Fake</b> & 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() +} |