From 6cce127e1cb73818a18f9064e666246eb5356309 Mon Sep 17 00:00:00 2001 From: abarrosjr <70298185+abarrosjr@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:14:24 -0300 Subject: [PATCH 1/3] Reapply "feat: Add 3-strike anti-spam system for captchas" This reverts commit 810eea542b846b052ee5bd4006463ca38fcf7146. --- src/ban.go | 11 +++++ src/bot.go | 8 ++++ src/captcha.go | 108 ++++++++++++++++++++++++++++++++++++++----------- 3 files changed, 103 insertions(+), 24 deletions(-) diff --git a/src/ban.go b/src/ban.go index f9b9a44..7d59341 100644 --- a/src/ban.go +++ b/src/ban.go @@ -5,6 +5,7 @@ import ( "log" "strings" "sync" + "time" tgbotapi "github.com/osprogramadores/telegram-bot-api" ) @@ -324,3 +325,13 @@ func kickUser(bot kickChatMemberer, chatID int64, userID int) error { _, err := bot.KickChatMember(tgbotapi.KickChatMemberConfig{ChatMemberConfig: memberConfig}) return err } + +// kickUserUntil bans a user until a specific time. +func kickUserUntil(bot kickChatMemberer, chatID int64, userID int, until time.Time) error { + memberConfig := tgbotapi.ChatMemberConfig{ChatID: chatID, UserID: userID} + _, err := bot.KickChatMember(tgbotapi.KickChatMemberConfig{ + ChatMemberConfig: memberConfig, + UntilDate: until.Unix(), + }) + return err +} diff --git a/src/bot.go b/src/bot.go index b14e387..649c8b0 100644 --- a/src/bot.go +++ b/src/bot.go @@ -33,6 +33,9 @@ type opBot struct { // List of users not yet validated by captcha. pendingCaptcha *pendingCaptchaType + // Track captcha failures across sessions + captchaFails *captchaFailures + // Don't send warning messages to new users on every infraction. newUserWarningCache *cache.Cache @@ -83,6 +86,7 @@ func newOpBot(config botConfig) (opBot, error) { newUserCache: cache.New(duration, duration), pendingCaptcha: newPendingCaptchaType(), + captchaFails: newCaptchaFailures(), // How often will re-send warning messages to offending new users. newUserWarningCache: cache.New(30*time.Minute, time.Hour), @@ -221,7 +225,11 @@ func (x *opBot) Run(bot *tgbotapi.BotAPI) { // and exit normally. promCaptchaValidatedCount.Inc() x.pendingCaptcha.del(userid) + x.captchaFails.reset(userid) x.sendWelcome(bot, update, *update.Message.From) + } else { + promCaptchaFailedCount.Inc() + x.handleCaptchaFailure(bot, chatid, msgid, *update.Message.From) } continue } diff --git a/src/captcha.go b/src/captcha.go index 9b3c744..203784d 100644 --- a/src/captcha.go +++ b/src/captcha.go @@ -143,7 +143,7 @@ func (x *opBot) captchaReaper(bot tgbotInterface, chatID int64, user tgbotapi.Us name := nameRef(user) // check if this user is already banned and possibly skip some of the following steps. - banned, err := isBanned(bot, chatID, user.ID) + _, err := isBanned(bot, chatID, user.ID) if err != nil { log.Printf("Warning: Unable to get information for user %s (uid=%d): %v", name, user.ID, err) } @@ -152,29 +152,7 @@ func (x *opBot) captchaReaper(bot tgbotInterface, chatID int64, user tgbotapi.Us // in the pending captcha list, meaning they didn't confirm the // captcha. - // Let's remove the pending request. - defer x.pendingCaptcha.del(user.ID) - - // Do nothing if user is already kicked (probably by an admin). - if banned { - log.Printf("User %s (uid=%d) has already been banned (by admin?) Not unbanning.", name, user.ID) - return - } - - // As the user is not yet banned, proceed to kick+unban. - // Kick user and remove from list. - _, err = sendMessage(bot, chatID, fmt.Sprintf(T("no_captcha_received"), name)) - if err != nil { - log.Printf("Warning: Unable to send 'invalid captcha' message.") - } - - if err = kickUser(bot, chatID, user.ID); err != nil { - log.Printf("Warning: Unable to kick user %s (uid=%d) out of the channel.", name, user.ID) - } - - if err = unBanUser(bot, chatID, user.ID); err != nil { - log.Printf("Warning: Unable to UNBAN user %s (uid=%d) (May be locked out.)", name, user.ID) - } + x.handleCaptchaFailure(bot, chatID, 0, user) }) } @@ -243,3 +221,85 @@ func bincode(s string) []byte { func captchaResendRequest(s string) bool { return strings.EqualFold(s, T("another_captcha")) } + +// handleCaptchaFailure deals with users who failed the captcha (timeout or wrong answer). +func (x *opBot) handleCaptchaFailure(bot tgbotInterface, chatID int64, messageID int, user tgbotapi.User) { + name := nameRef(user) + fails := x.captchaFails.increment(user.ID) + + log.Printf("User %s (uid=%d) failed captcha. Total fails: %d", name, user.ID, fails) + + // Remove from pending + x.pendingCaptcha.del(user.ID) + + banned, err := isBanned(bot, chatID, user.ID) + if err != nil { + log.Printf("Warning: Unable to get information for user %s (uid=%d): %v", name, user.ID, err) + } + + if banned { + log.Printf("User %s (uid=%d) has already been banned. Not doing anything.", name, user.ID) + return + } + + switch fails { + case 1: + sendMessage(bot, chatID, fmt.Sprintf(T("captcha_fail_1"), name)) + kickUser(bot, chatID, user.ID) + unBanUser(bot, chatID, user.ID) + case 2: + sendMessage(bot, chatID, fmt.Sprintf(T("captcha_fail_2"), name)) + kickUser(bot, chatID, user.ID) + unBanUser(bot, chatID, user.ID) + case 3: + sendMessage(bot, chatID, fmt.Sprintf(T("captcha_fail_3"), name)) + kickUserUntil(bot, chatID, user.ID, time.Now().Add(24*time.Hour)) + default: + sendMessage(bot, chatID, fmt.Sprintf(T("captcha_fail_max"), name)) + banUser(bot, chatID, user.ID) + } +} + +const captchaFailuresDB = "captcha_failures.json" + +type captchaFailures struct { + sync.RWMutex + Failures map[int]int `json:"failures"` +} + +func newCaptchaFailures() *captchaFailures { + cf := &captchaFailures{ + Failures: map[int]int{}, + } + cf.load() + return cf +} + +func (c *captchaFailures) load() { + c.Lock() + defer c.Unlock() + readJSONFromDataDir(&c.Failures, captchaFailuresDB) + if c.Failures == nil { + c.Failures = map[int]int{} + } +} + +func (c *captchaFailures) save() error { + return safeWriteJSON(c.Failures, captchaFailuresDB) +} + +func (c *captchaFailures) increment(userID int) int { + c.Lock() + defer c.Unlock() + c.Failures[userID]++ + fails := c.Failures[userID] + c.save() + return fails +} + +func (c *captchaFailures) reset(userID int) { + c.Lock() + defer c.Unlock() + delete(c.Failures, userID) + c.save() +} From cd62adb0e8446ec74cc2fdb71898d4715d75c42e Mon Sep 17 00:00:00 2001 From: Leo Silva Souza <13321466+leovano@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:06:32 -0300 Subject: [PATCH 2/3] fix(captcha): prevent instant kick on wrong guess or service message --- src/bot.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/bot.go b/src/bot.go index 649c8b0..a81a45f 100644 --- a/src/bot.go +++ b/src/bot.go @@ -227,9 +227,6 @@ func (x *opBot) Run(bot *tgbotapi.BotAPI) { x.pendingCaptcha.del(userid) x.captchaFails.reset(userid) x.sendWelcome(bot, update, *update.Message.From) - } else { - promCaptchaFailedCount.Inc() - x.handleCaptchaFailure(bot, chatid, msgid, *update.Message.From) } continue } From bf083a20c7ffb0c19a7cfa6278f7d3148501cc91 Mon Sep 17 00:00:00 2001 From: Leo Silva Souza <13321466+leovano@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:10:07 -0300 Subject: [PATCH 3/3] feat(captcha): add prometheus metrics for failure stages --- src/captcha.go | 4 ++++ src/prometheus.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/captcha.go b/src/captcha.go index 203784d..0f20d2a 100644 --- a/src/captcha.go +++ b/src/captcha.go @@ -244,17 +244,21 @@ func (x *opBot) handleCaptchaFailure(bot tgbotInterface, chatID int64, messageID switch fails { case 1: + promCaptchaFail1Count.Inc() sendMessage(bot, chatID, fmt.Sprintf(T("captcha_fail_1"), name)) kickUser(bot, chatID, user.ID) unBanUser(bot, chatID, user.ID) case 2: + promCaptchaFail2Count.Inc() sendMessage(bot, chatID, fmt.Sprintf(T("captcha_fail_2"), name)) kickUser(bot, chatID, user.ID) unBanUser(bot, chatID, user.ID) case 3: + promCaptchaFail3Count.Inc() sendMessage(bot, chatID, fmt.Sprintf(T("captcha_fail_3"), name)) kickUserUntil(bot, chatID, user.ID, time.Now().Add(24*time.Hour)) default: + promCaptchaFailMaxCount.Inc() sendMessage(bot, chatID, fmt.Sprintf(T("captcha_fail_max"), name)) banUser(bot, chatID, user.ID) } diff --git a/src/prometheus.go b/src/prometheus.go index a32f31e..224cfa5 100644 --- a/src/prometheus.go +++ b/src/prometheus.go @@ -57,6 +57,30 @@ var ( Help: "Number of users kicked or banned by message pattern matching", }, ) + promCaptchaFail1Count = prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "opbot_captchas_fail_1_total", + Help: "Total count of 1st captcha failures", + }, + ) + promCaptchaFail2Count = prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "opbot_captchas_fail_2_total", + Help: "Total count of 2nd captcha failures", + }, + ) + promCaptchaFail3Count = prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "opbot_captchas_fail_3_total", + Help: "Total count of 3rd captcha failures", + }, + ) + promCaptchaFailMaxCount = prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "opbot_captchas_fail_max_total", + Help: "Total count of max captcha failures", + }, + ) ) func init() { @@ -69,6 +93,10 @@ func init() { promRichMessageDeletedCount, promPatternMessageDeletedCount, promPatternKickBannedCount, + promCaptchaFail1Count, + promCaptchaFail2Count, + promCaptchaFail3Count, + promCaptchaFailMaxCount, ) // Add handlers.