Files
bunny-bot/internal/moon/greetSweep.go
eleksir 0c34b6e781 Global code ovehaul - remove CAS and other, add 15 minute greet check
- Remove CAS, as it no longer works for us
- Remove other checks
- Implement 15 minute greet timeout check
- Fix chat logos
- Ditch thirdparty logger, use stdlib log facility wrapper for logging
2026-10-20 12:20:16 +03:00

207 lines
6.0 KiB
Go

package moon
import (
"bunny-bot/internal/mylog"
"time"
"github.com/NicoNex/echotron/v3"
)
// SweepGreetings runs until a termination signal stops it. Every
// greetSweepInterval it looks at join events that are at least greetReplyWindow
// old and decides whether each newcomer ever answered the greeting:
//
// - answered (a message from that user exists after the join, within the
// reply window) -> newcomer is legitimate, drop the join event(s);
// - not answered -> newcomer is a spammer, ban them and drop the join
// event(s).
//
// A user may appear in PendingGreetings more than once (rejoins). For every user we
// re-query both caches so that a single answer rescues all of their join events
// and a single unanswered join bans them once.
//
// The stop is checked between passes and never during one, so a sweep in
// progress always finishes and can never be running while the caches are being
// closed underneath it.
func SweepGreetings() {
if !startWorker() {
return
}
defer stopWorker()
timer := time.NewTimer(greetSweepInterval)
defer timer.Stop()
for {
select {
case <-stopRequested:
mylog.Info("Greeting sweeper stopped")
return
case <-timer.C:
sweepGreetings()
timer.Reset(greetSweepInterval)
}
}
}
// sweepGreetings performs a single sweep pass against the current wall clock.
func sweepGreetings() {
sweepGreetingsAt(time.Now())
}
// sweepGreetingsAt performs a single sweep pass as of now. Taking the clock as
// a parameter is what makes the reply-window boundary testable without sleeping
// for 15 minutes.
func sweepGreetingsAt(now time.Time) {
joins := PendingGreetings.Snapshot()
messages := GreetAnswers.Snapshot()
windowMicros := greetReplyWindow.Microseconds()
nowMicros := now.UnixMicro()
// Group stale join events per (chat, user). A user is a distinct identity in
// every chat: being silent in one chat says nothing about another, and
// answering in one chat must not rescue them in another.
staleByMember := make(map[chatUser][]joinRef)
for joinMicros, recorded := range joins {
join := recorded.join
// Not stale yet.
if nowMicros-joinMicros <= windowMicros {
continue
}
// A join event carries the newcomer's user in NewChatMembers.
if join == nil || len(join.NewChatMembers) == 0 {
continue
}
user := join.NewChatMembers[0]
// An entity with no id cannot be banned, and the dump has already been
// taken. Drop the join rather than keeping it: re-reporting the same
// broken update every minute for the rest of its TTL is noise that
// teaches an operator to ignore this log.
if !usableUser(user) {
PendingGreetings.Delete(joinMicros)
continue
}
member := chatUser{chatID: join.Chat.ID, userID: user.ID}
staleByMember[member] = append(staleByMember[member], joinRef{
micros: joinMicros,
chat: join.Chat,
user: user,
greetingID: recorded.greetingID,
})
}
for member, refs := range staleByMember {
// A single answer rescues every join this user has *in this chat*, no
// matter which join the reply was actually answering.
answered := false
for _, ref := range refs {
if userAnswered(member.chatID, member.userID, ref.micros, windowMicros, messages) {
answered = true
break
}
}
if answered {
mylog.Debugf(
"User %d answered the greeting in chat %d, not banning",
member.userID,
member.chatID,
)
} else {
mylog.Infof(
"User %d did not answer the greeting in chat %d within %s, banning as spammer",
member.userID,
member.chatID,
greetReplyWindow,
)
// squash already logs every outcome at the level it deserves: a
// ban that landed is a debug line and a duplicate we deliberately
// suppressed is an info line. Only a refused ban needs anything
// added here, because it is the one case where the user is still
// in the chat and will never be re-judged.
outcome := squash(&refs[0].chat, refs[0].user)
if outcome == banFailed {
mylog.Errorf(
"BAN FAILED for user %d in chat %d — they stayed in the chat and were not re-judged",
member.userID,
member.chatID,
)
} else {
// The user is out of the chat, so every question we asked them
// is a leftover. That includes the debounced repeat: they are
// banned either way, and the room must not be left asking a
// question nobody can answer.
retractAskedGreetings(refs)
}
}
// Drop every join event of this user *in this chat* so the next pass
// does not re-decide them. Joins in their other chats are untouched.
forgetPendingGreetings(member.chatID, member.userID)
}
}
// userAnswered reports whether a message authored by userID in chatID exists
// in messages with a timestamp in (joinMicros, joinMicros+windowMicros].
//
// Both the chat and the author must match: the newcomer must answer in the same
// chat they were greeted in. Messages sent before the join do not count either,
// because the greeting is only sent in response to the join.
func userAnswered(
chatID int64,
userID int64,
joinMicros int64,
windowMicros int64,
messages map[int64]*echotron.Message,
) bool {
for msgMicros, msg := range messages {
if msg == nil || msg.From == nil {
continue
}
if msg.From.ID != userID || msg.Chat.ID != chatID {
continue
}
if msgMicros > joinMicros && msgMicros <= joinMicros+windowMicros {
return true
}
}
return false
}
// forgetPendingGreetings removes every PendingGreetings entry whose newcomer is userID in the
// given chat. Joins by the same user in other chats are deliberately preserved:
// those are independent memberships with their own greetings and their own
// verdicts.
func forgetPendingGreetings(chatID int64, userID int64) {
for joinMicros, recorded := range PendingGreetings.Snapshot() {
join := recorded.join
if join == nil || join.Chat.ID != chatID || len(join.NewChatMembers) == 0 {
continue
}
if join.NewChatMembers[0] != nil && join.NewChatMembers[0].ID == userID {
PendingGreetings.Delete(joinMicros)
}
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */