// 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. // // A failure may be a [*cepis.Error] carrying 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) } // 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), inlineErrorDescription(err), 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" } // 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 := faixaOf(err); faixa != nil { return "Cairia em " + place(faixa) + " — 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. 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) }