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..a81a45f 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,6 +225,7 @@ 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) } continue diff --git a/src/captcha.go b/src/captcha.go index 9b3c744..0f20d2a 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,89 @@ 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: + 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) + } +} + +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() +} 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.