summaryrefslogtreecommitdiffstats
path: root/internal/bot/bot.go
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/bot.go
downloadtelegram-3ca1d2c4370e8e79aba3940d5c8fe115960b75a8.tar.gz
Initial importv0.1.0
Diffstat (limited to 'internal/bot/bot.go')
-rw-r--r--internal/bot/bot.go288
1 files changed, 288 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) }