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/bot.go | |
| download | telegram-3ca1d2c4370e8e79aba3940d5c8fe115960b75a8.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'internal/bot/bot.go')
| -rw-r--r-- | internal/bot/bot.go | 288 |
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) } |