Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@
- Training/dry/soft-ban: feature emits a normal spam result through the existing pipeline, so the listener's mode handling intercepts identically to other spam checks (no special-casing)
- Naturally-terse legitimate users carry a bounded false-positive risk during the evaluation window only; once the user crosses `FirstMessagesCount` they are approved and immune. Recommended baseline: `MaxShortMsgCount >= 3` with low `FirstMessagesCount` (1–2)

### Reaction Ban Notifications
- Reaction-spammer bans (`procReaction` in `app/events/listener.go`) are reported to the admin chat via `admin.ReportReactionBan`, which reuses the same `change ban`/`info` inline keyboard as `ReportBan` (`sendWithUnbanMarkup`) so admins can unban+approve a wrongly banned reaction spammer
- Reactions have no underlying message, so the callback carries `msgID = 0` as a sentinel. Two paths must honor it: `deleteAndBan` skips the Telegram delete when `msgID == 0` (removing the `if msgID != 0` guard would make every reaction-ban confirmation try to delete message 0), and `callbackUnbanConfirmed` already discards msgID (`userID, _, err := parseCallbackData(...)`)
- The notification text keeps the `[user](tg://user?id=N)` link immediately after "permanently banned" because Telegram strips markdown from callback text; `extractUsername`'s plain-channel regex would otherwise capture any words placed between "permanently banned" and the trailing `(id)` as the approved-user name
- `getCleanMessage` returns empty/error on the short reaction notification (no message body), so the unban path correctly skips `UpdateHam` — no `[reaction spam]` sample is learned

### LLM Checker Structure
- Shared provider-agnostic LLM flow lives in `lib/tgspam/llm.go`
- Keep provider-specific transport and request construction in `lib/tgspam/openai.go`, `lib/tgspam/gemini.go`, etc
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ This option is disabled by default. When enabled, the bot tracks emoji reactions

Only applies to unapproved users — approved users are exempt from reaction spam detection.

Reaction bans are reported to the admin chat with the same `change ban` and `info` buttons as message-based bans, so a wrongly banned reaction spammer can be unbanned and approved straight from the notification (`change ban` opens an unban/keep-banned confirmation).

Note: the bot always subscribes to `message_reaction` updates from Telegram on startup, regardless of whether `--reactions.max-reactions` is set. This is a change from previous versions where no reaction updates were requested.

Configure with:
Expand Down
48 changes: 37 additions & 11 deletions app/events/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ func (a *admin) ReportBan(banUserStr string, msg *bot.Message) {
}
}

// ReportReactionBan sends a reaction-spammer ban notification to admin chat with the same unban/info buttons as ReportBan.
// reactions have no underlying message, so msgID is 0; the unban path ignores it and deleteAndBan skips deletion for 0.
func (a *admin) ReportReactionBan(banUserStr string, user bot.User) {
link := fmt.Sprintf("[%s](tg://user?id=%d)", escapeMarkDownV1Text(banUserStr), user.ID)
// keep the user link immediately after "permanently banned" so extractUsername parses it cleanly on unban;
// telegram strips markdown from callback text, so any words placed between the two get captured as the name
text := fmt.Sprintf("**permanently banned %s reaction spammer**\n\n", link)
switch {
case a.trainingMode:
text = fmt.Sprintf("**[training] would have permanently banned %s reaction spammer**\n\n", link)
case a.dry:
text = fmt.Sprintf("**[dry run] would have permanently banned %s reaction spammer**\n\n", link)
}
if err := a.sendWithUnbanMarkup(text, "change ban", user, 0, a.adminChatID); err != nil {
log.Printf("[WARN] failed to send reaction ban notification: %v", err)
}
}

// MsgHandler handles messages received on admin chat. this is usually forwarded spam failed
// to be detected by the bot. we need to update spam filter with this message and ban the user.
// the user will be banned even in training mode, but not in the dry mode.
Expand Down Expand Up @@ -838,7 +856,7 @@ func (a *admin) callbackBanConfirmed(query *tbapi.CallbackQuery) error {

if a.trainingMode {
// in training mode, the user is not banned automatically, here we do the real ban & delete the message
if err := a.deleteAndBan(query, userID, msgID); err != nil {
if err := a.deleteAndBan(userID, msgID); err != nil {
return fmt.Errorf("failed to ban user %d: %w", userID, err)
}
}
Expand Down Expand Up @@ -1032,8 +1050,9 @@ func (a *admin) callbackShowInfo(query *tbapi.CallbackQuery) error {
return nil
}

// deleteAndBan deletes the message and bans the user
func (a *admin) deleteAndBan(query *tbapi.CallbackQuery, userID int64, msgID int) error {
// deleteAndBan bans the user and deletes the message; deletion is skipped when msgID is 0
// (reaction bans have no underlying message).
func (a *admin) deleteAndBan(userID int64, msgID int) error {
errs := new(multierror.Error)
userName := a.locator.UserNameByID(context.TODO(), userID)
banReq := banRequest{
Expand All @@ -1055,13 +1074,16 @@ func (a *admin) deleteAndBan(query *tbapi.CallbackQuery, userID int64, msgID int
}
}

// reaction bans have no underlying message (msgID 0), so there is nothing to delete.
// we allow deleting messages from supers. This can be useful if super is training the bot by adding spam messages
_, err := a.tbAPI.Request(tbapi.DeleteMessageConfig{BaseChatMessage: tbapi.BaseChatMessage{
MessageID: msgID,
ChatConfig: tbapi.ChatConfig{ChatID: a.primChatID},
}})
if err != nil {
return fmt.Errorf("failed to delete message %d: %w", query.Message.MessageID, err)
if msgID != 0 {
_, err := a.tbAPI.Request(tbapi.DeleteMessageConfig{BaseChatMessage: tbapi.BaseChatMessage{
MessageID: msgID,
ChatConfig: tbapi.ChatConfig{ChatID: a.primChatID},
}})
if err != nil {
return fmt.Errorf("failed to delete message %d: %w", msgID, err)
}
}

// any errors happened above will be returned
Expand All @@ -1074,10 +1096,14 @@ func (a *admin) deleteAndBan(query *tbapi.CallbackQuery, userID int64, msgID int
return errors.New(strings.Join(errMsgs, "\n")) // reformat to be md friendly
}

deletedPart := ""
if msgID != 0 {
deletedPart = fmt.Sprintf("message %d deleted, ", msgID)
}
if msgFromSuper {
log.Printf("[INFO] message %d deleted, user %q (%d) is super, not banned", msgID, userName, userID)
log.Printf("[INFO] %suser %q (%d) is super, not banned", deletedPart, userName, userID)
} else {
log.Printf("[INFO] message %d deleted, user %q (%d) banned", msgID, userName, userID)
log.Printf("[INFO] %suser %q (%d) banned", deletedPart, userName, userID)
}
return nil
}
Expand Down
78 changes: 78 additions & 0 deletions app/events/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ func TestAdmin_getCleanMessage(t *testing.T) {
expected: "",
err: true,
},
{
name: "reaction ban notification has no body",
input: "permanently banned @spammer (42) reaction spammer",
expected: "",
err: true,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -173,6 +179,10 @@ func TestAdmin_extractUsername(t *testing.T) {
{name: "t.me channel link", banMessage: "**permanently banned [spamchannel](https://t.me/spamchannel)**\n\nspam text", expectedResult: "spamchannel"},
{name: "plain channel with ID", banMessage: "**permanently banned mychannel (-100999888)**\n\nspam text", expectedResult: "mychannel"},
{name: "plain channel multi-word title", banMessage: "**permanently banned Spam News Channel (-100999888)**\n\ntext", expectedResult: "Spam News Channel"},
{name: "reaction markdown", banMessage: "**permanently banned [@spammer (42)](tg://user?id=42) reaction spammer**\n\n", expectedResult: "@spammer (42)"},
{name: "reaction rendered", banMessage: "permanently banned @spammer (42) reaction spammer", expectedResult: "@spammer"},
{name: "reaction rendered dry", banMessage: "[dry run] would have permanently banned @spammer (42) reaction spammer", expectedResult: "@spammer"},
{name: "reaction rendered training", banMessage: "[training] would have permanently banned @spammer (42) reaction spammer", expectedResult: "@spammer"},
{name: "invalid format", banMessage: "permanently banned John_Doe some message text", expectError: true},
}

Expand Down Expand Up @@ -1228,6 +1238,74 @@ func TestAdmin_InlineCallbacks(t *testing.T) {
assert.Equal(t, int64(123), lastCall.C.(tbapi.DeleteMessageConfig).ChatID)
})

t.Run("callbackBanConfirmed_TrainingMode_reactionNoMsgID", func(t *testing.T) {
mockAPI, _, adm, query := setupCallback(true, false)
query.Data = "+12345:0" // reaction ban has no underlying message to delete
query.Message.Text = "permanently banned @spammer (12345) reaction spammer" // reaction notif has no message body

err := adm.callbackBanConfirmed(query)
require.NoError(t, err)

banned := false
for _, c := range mockAPI.RequestCalls() {
_, isDelete := c.C.(tbapi.DeleteMessageConfig)
assert.False(t, isDelete, "no message deletion when msgID is 0")
if _, ok := c.C.(tbapi.BanChatMemberConfig); ok {
banned = true
}
}
assert.True(t, banned, "user still banned for real on training-mode confirm")

// admin message is edited to confirm the ban
require.GreaterOrEqual(t, len(mockAPI.SendCalls()), 1)
editMsg, ok := mockAPI.SendCalls()[0].C.(tbapi.EditMessageTextConfig)
require.True(t, ok)
assert.Contains(t, editMsg.Text, "ban confirmed by admin")
})

t.Run("callbackUnbanConfirmed_reaction", func(t *testing.T) {
mockAPI, _, adm, _ := setupCallback(false, false)
botMock := &mocks.BotMock{
UpdateHamFunc: func(msg string) error { return nil },
AddApprovedUserFunc: func(id int64, name string) error { return nil },
}
adm.bot = botMock

// reaction ban: msgID 0, rendered (markdown-stripped) notification text as telegram returns it on callback
query := &tbapi.CallbackQuery{
ID: "test-callback-id",
Data: "42:0",
Message: &tbapi.Message{
MessageID: 789,
Chat: tbapi.Chat{ID: 456},
Text: "permanently banned @spammer (42) reaction spammer",
From: &tbapi.User{UserName: "bot"},
},
From: &tbapi.User{UserName: "admin", ID: 111},
}

err := adm.callbackUnbanConfirmed(query)
require.NoError(t, err)

// regular user unban uses UnbanChatMemberConfig (not channel), no message deletion for msgID 0
var foundUnban bool
for _, call := range mockAPI.RequestCalls() {
_, isDelete := call.C.(tbapi.DeleteMessageConfig)
assert.False(t, isDelete, "no message deletion on reaction unban")
if unbanCall, ok := call.C.(tbapi.UnbanChatMemberConfig); ok {
foundUnban = true
assert.Equal(t, int64(42), unbanCall.UserID)
assert.Equal(t, int64(123), unbanCall.ChatID)
}
}
assert.True(t, foundUnban, "expected UnbanChatMemberConfig for reaction spammer")

// approved user added with the clean extracted username, not "reaction spammer @spammer"
require.Len(t, botMock.AddApprovedUserCalls(), 1)
assert.Equal(t, int64(42), botMock.AddApprovedUserCalls()[0].ID)
assert.Equal(t, "@spammer", botMock.AddApprovedUserCalls()[0].Name)
})

t.Run("callbackBanConfirmed_SoftBan", func(t *testing.T) {
mockAPI, botMock, adm, query := setupCallback(false, true)

Expand Down
13 changes: 1 addition & 12 deletions app/events/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -808,18 +808,7 @@ func (l *TelegramListener) procReaction(ctx context.Context, r *tbapi.MessageRea
return fmt.Errorf("failed to ban reaction spammer %s: %w", banUserStr, err)
}
if l.adminChatID != 0 && resp.User.ID != 0 {
// reactions don't have a message ID to delete, so send a plain notification without action buttons
notifText := fmt.Sprintf("permanently banned reaction spammer %s", banUserStr)
switch {
case l.TrainingMode:
notifText = fmt.Sprintf("[training] reaction spammer detected: %s", banUserStr)
case l.Dry:
notifText = fmt.Sprintf("[dry run] would ban reaction spammer %s", banUserStr)
}
notif := tbapi.NewMessage(l.adminChatID, notifText)
if _, err := l.TbAPI.Send(notif); err != nil {
log.Printf("[WARN] failed to send reaction ban notification: %v", err)
}
l.adminHandler.ReportReactionBan(banUserStr, resp.User)
}
return nil
}
Expand Down
58 changes: 58 additions & 0 deletions app/events/listener_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4561,6 +4561,54 @@ func TestProcReaction(t *testing.T) {
assert.Equal(t, "[reaction spam]", spamLoggerMock.SaveCalls()[0].Msg.Text)
})

t.Run("admin notification includes change-ban and info buttons", func(t *testing.T) {
mockAPI := makeAPI(t)
locatorMock := &mocks.LocatorMock{
AddSpamFunc: func(ctx context.Context, userID int64, checks []spamcheck.Response) error { return nil },
}
spamLoggerMock := &mocks.SpamLoggerMock{SaveFunc: func(msg *bot.Message, response *bot.Response) {}}
botMock := &mocks.BotMock{
OnReactionFunc: func(userID int64, userName string) bot.Response {
return bot.Response{
BanInterval: bot.PermanentBanDuration,
User: bot.User{ID: 42, Username: "spammer"},
CheckResults: []spamcheck.Response{{Name: "reactions", Spam: true, Details: "5 reactions in 1s"}},
}
},
}
l := TelegramListener{TbAPI: mockAPI, Bot: botMock, Locator: locatorMock, SpamLogger: spamLoggerMock}
l.chatID = 123
l.adminChatID = 999
l.adminHandler = &admin{tbAPI: mockAPI, bot: botMock, locator: locatorMock, adminChatID: 999, primChatID: 123}

err := l.procReaction(context.Background(), &tbapi.MessageReactionUpdated{
Chat: tbapi.Chat{ID: 123},
User: &tbapi.User{ID: 42, UserName: "spammer"},
NewReaction: []tbapi.ReactionType{{Type: "emoji", Emoji: "👍"}},
})
require.NoError(t, err)

require.Len(t, mockAPI.SendCalls(), 1, "notification should be sent to admin chat")
sentMsg, ok := mockAPI.SendCalls()[0].C.(tbapi.MessageConfig)
require.True(t, ok)
assert.Equal(t, int64(999), sentMsg.ChatID)
assert.Contains(t, sentMsg.Text, "permanently banned")
assert.Contains(t, sentMsg.Text, "reaction spammer")
// user link sits immediately after "permanently banned" so extractUsername parses it cleanly on unban
assert.Contains(t, sentMsg.Text, "permanently banned [@spammer (42)](tg://user?id=42)")

markup, ok := sentMsg.ReplyMarkup.(tbapi.InlineKeyboardMarkup)
require.True(t, ok, "notification should carry inline keyboard")
require.Len(t, markup.InlineKeyboard, 1)
require.Len(t, markup.InlineKeyboard[0], 2, "should have change-ban and info buttons")
require.NotNil(t, markup.InlineKeyboard[0][0].CallbackData)
require.NotNil(t, markup.InlineKeyboard[0][1].CallbackData)
assert.Contains(t, markup.InlineKeyboard[0][0].Text, "change ban", "first button is change ban")
assert.Contains(t, markup.InlineKeyboard[0][1].Text, "info", "second button is info")
assert.Equal(t, "?42:0", *markup.InlineKeyboard[0][0].CallbackData, "change-ban callback, msgID 0 for reactions")
assert.Equal(t, "!42:0", *markup.InlineKeyboard[0][1].CallbackData, "info callback, msgID 0 for reactions")
})

t.Run("superuser reaction ignored", func(t *testing.T) {
mockAPI := makeAPI(t)
botMock := &mocks.BotMock{}
Expand Down Expand Up @@ -4760,6 +4808,11 @@ func TestProcReaction(t *testing.T) {
msg := sendCalls[0].C.(tbapi.MessageConfig)
assert.Equal(t, int64(456), msg.ChatID)
assert.Contains(t, msg.Text, "[dry run]")
// buttons are attached regardless of mode
markup, ok := msg.ReplyMarkup.(tbapi.InlineKeyboardMarkup)
require.True(t, ok, "dry-run notification should carry inline keyboard")
require.Len(t, markup.InlineKeyboard, 1)
require.Len(t, markup.InlineKeyboard[0], 2)
})

t.Run("threshold reached, training mode admin notified", func(t *testing.T) {
Expand Down Expand Up @@ -4800,5 +4853,10 @@ func TestProcReaction(t *testing.T) {
msg := sendCalls[0].C.(tbapi.MessageConfig)
assert.Equal(t, int64(456), msg.ChatID)
assert.Contains(t, msg.Text, "[training]")
// buttons are attached regardless of mode
markup, ok := msg.ReplyMarkup.(tbapi.InlineKeyboardMarkup)
require.True(t, ok, "training notification should carry inline keyboard")
require.Len(t, markup.InlineKeyboard, 1)
require.Len(t, markup.InlineKeyboard[0], 2)
})
}