Skip to content

Commit b685ded

Browse files
authored
Fix: NOT SPAM button in potential spam was banning users (#98)
For potential spam, both KILL and NOT SPAM buttons shared the same callback with Spam type. Now each button has its own callback with correct type. Added tests and helper for cleanup of related callbacks.
1 parent 9a19594 commit b685ded

File tree

4 files changed

+85
-22
lines changed

4 files changed

+85
-22
lines changed

src/VahterBanBot.Tests/ContainerTestBase.fs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,14 @@ type VahterTestContainers(mlEnabled: bool) =
247247
return count > 0
248248
}
249249

250+
member _.UserBanned(userId: int64) = task {
251+
use conn = new NpgsqlConnection(publicConnectionString)
252+
//language=postgresql
253+
let sql = "SELECT COUNT(*) FROM banned WHERE banned_user_id = @userId"
254+
let! count = conn.QuerySingleAsync<int>(sql, {| userId = userId |})
255+
return count > 0
256+
}
257+
250258
member _.MessageIsAutoDeleted(msg: Message) = task {
251259
use conn = new NpgsqlConnection(publicConnectionString)
252260
//language=postgresql

src/VahterBanBot.Tests/MLBanTests.fs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ type MLBanTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwaitFixture)
8282

8383
[<Fact>]
8484
let ``If message got auto-deleted we can mark it as false-positive with a button click`` () = task {
85-
// record a message, where 2 is in a training set as spam word
85+
// record a message, where 7 is in a training set as spam word (detected spam)
8686
// ChatsToMonitor[0] doesn't have stopwords
8787
let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "7777777")
8888
let! _ = fixture.SendMessage msgUpdate
@@ -93,15 +93,50 @@ type MLBanTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwaitFixture)
9393
// assert it is not false-positive
9494
let! isFalsePositive = fixture.IsMessageFalsePositive msgUpdate.Message
9595
Assert.False isFalsePositive
96+
// assert user is NOT banned (auto-delete is not the same as ban)
97+
let! userBanned = fixture.UserBanned msgUpdate.Message.From.Id
98+
Assert.False userBanned
9699

97-
// send a callback to mark it as false-positive
100+
// send a callback to mark it as false-positive (NOT SPAM button)
98101
let! callbackId = fixture.GetCallbackId msgUpdate.Message "NotASpam"
99102
let msgCallback = Tg.callback(string callbackId, from = fixture.Vahters[0])
100103
let! _ = fixture.SendMessage msgCallback
101104

102105
// assert it is false-positive
103106
let! isFalsePositive = fixture.IsMessageFalsePositive msgUpdate.Message
104107
Assert.True isFalsePositive
108+
// CRITICAL: assert user is still NOT banned after NOT SPAM button
109+
let! userBanned = fixture.UserBanned msgUpdate.Message.From.Id
110+
Assert.False userBanned
111+
}
112+
113+
[<Fact>]
114+
let ``Potential spam NOT SPAM button does not ban user`` () = task {
115+
// For potential spam, we need a text that gives score >= 0 but < 1.0 (ML_SPAM_THRESHOLD)
116+
// Using a shorter spam-like text that might give lower score
117+
// This tests the fix for the bug where NOT SPAM button was calling vahterMarkedAsSpam
118+
let user = Tg.user()
119+
let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "77", from = user)
120+
let! _ = fixture.SendMessage msgUpdate
121+
122+
// For potential spam, message is NOT auto-deleted (deleteMessage = false)
123+
let! msgAutoDeleted = fixture.MessageIsAutoDeleted msgUpdate.Message
124+
// If this is potential spam, it won't be auto-deleted
125+
// If it IS auto-deleted, it means score >= threshold, but the test still validates the bug fix
126+
127+
// User should NOT be banned initially
128+
let! userBannedBefore = fixture.UserBanned user.Id
129+
Assert.False userBannedBefore
130+
131+
// Try to get NotASpam callback (this exists for both detected and potential spam)
132+
let! callbackId = fixture.GetCallbackId msgUpdate.Message "NotASpam"
133+
let msgCallback = Tg.callback(string callbackId, from = fixture.Vahters[0])
134+
let! _ = fixture.SendMessage msgCallback
135+
136+
// CRITICAL: User should still NOT be banned after NOT SPAM button
137+
// This is the main assertion for the bug fix
138+
let! userBannedAfter = fixture.UserBanned user.Id
139+
Assert.False userBannedAfter
105140
}
106141

107142
[<Fact>]

src/VahterBanBot/Bot.fs

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -484,38 +484,42 @@ let killSpammerAutomated
484484
let msgType = if deleteMessage then "Deleted" else "Detected"
485485
let logMsg = $"{msgType} spam (score: {score}) in {prependUsername message.Chat.Username} ({message.Chat.Id}) from {prependUsername message.From.Username} ({message.From.Id}) with text:\n{message.TextOrCaption}"
486486

487-
// Determine which topic and what buttons
488-
let topicId, replyMarkup =
487+
// Determine which topic
488+
let topicId =
489+
if deleteMessage then botConfig.ActionDetectedTopicId
490+
else botConfig.ActionPotentialTopicId
491+
492+
// Phase 1: Create callback(s) without message_id
493+
// For detected spam: one NotASpam callback
494+
// For potential spam: two callbacks - Spam (KILL) and NotASpam (NOT SPAM)
495+
let! callbackIds, markup = task {
489496
if deleteMessage then
490-
// Detected spam → Detected topic with NOT A SPAM button
491-
botConfig.ActionDetectedTopicId,
492-
fun (callbackId: Guid) -> InlineKeyboardMarkup [
493-
InlineKeyboardButton.WithCallbackData("✅ NOT a spam", string callbackId)
497+
// Detected spam → one NotASpam callback
498+
let! callback = DB.newCallbackPending (CallbackMessage.NotASpam { message = message }) message.From.Id topicId
499+
return [callback.id], InlineKeyboardMarkup [
500+
InlineKeyboardButton.WithCallbackData("✅ NOT a spam", string callback.id)
494501
]
495502
else
496-
// Potential spam → Potential topic with KILL and NOT SPAM buttons
497-
botConfig.ActionPotentialTopicId,
498-
fun (callbackId: Guid) -> InlineKeyboardMarkup [|
499-
InlineKeyboardButton.WithCallbackData("🚫 KILL", string callbackId);
500-
InlineKeyboardButton.WithCallbackData("✅ NOT SPAM", string callbackId)
503+
// Potential spam → two callbacks
504+
let! killCallback = DB.newCallbackPending (CallbackMessage.Spam { message = message }) message.From.Id topicId
505+
let! notSpamCallback = DB.newCallbackPending (CallbackMessage.NotASpam { message = message }) message.From.Id topicId
506+
return [killCallback.id; notSpamCallback.id], InlineKeyboardMarkup [|
507+
InlineKeyboardButton.WithCallbackData("🚫 KILL", string killCallback.id);
508+
InlineKeyboardButton.WithCallbackData("✅ NOT SPAM", string notSpamCallback.id)
501509
|]
502-
503-
// Phase 1: Create callback without message_id
504-
let callbackData =
505-
if deleteMessage then CallbackMessage.NotASpam { message = message }
506-
else CallbackMessage.Spam { message = message }
507-
let! callback = DB.newCallbackPending callbackData message.From.Id topicId
510+
}
508511

509512
// Phase 2: Post to action channel topic
510513
let! sent = botClient.SendTextMessageAsync(
511514
chatId = ChatId(botConfig.ActionChannelId),
512515
text = logMsg,
513516
messageThreadId = topicId,
514-
replyMarkup = replyMarkup callback.id
517+
replyMarkup = markup
515518
)
516519

517-
// Phase 3: Update callback with message_id
518-
do! DB.updateCallbackMessageId callback.id sent.MessageId
520+
// Phase 3: Update all callbacks with message_id
521+
for callbackId in callbackIds do
522+
do! DB.updateCallbackMessageId callbackId sent.MessageId
519523

520524
// Send to All Logs topic (readonly, no buttons)
521525
do! botClient.SendTextMessageAsync(
@@ -932,8 +936,12 @@ let onCallbackAux
932936
do! botClient.AnswerCallbackQueryAsync(callbackQuery.Id, "Already handled by another vahter")
933937

934938
// Always delete message from action channel (empty inbox)
939+
// and cleanup related callbacks (for potential spam with two buttons)
935940
match dbCallback.action_message_id with
936941
| Some msgId ->
942+
// Delete other callbacks with same message_id (the other button)
943+
do! DB.deleteCallbacksByMessageId msgId
944+
// Delete message from channel
937945
do! botClient.DeleteMessageAsync(
938946
ChatId(botConfig.ActionChannelId),
939947
msgId

src/VahterBanBot/DB.fs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,18 @@ let deleteCallback (id: Guid): Task =
332332
return ()
333333
}
334334

335+
/// Deletes all callbacks with the same action_message_id (for potential spam with two buttons)
336+
let deleteCallbacksByMessageId (actionMessageId: int): Task =
337+
task {
338+
use conn = new NpgsqlConnection(connString)
339+
340+
//language=postgresql
341+
let sql = "DELETE FROM callback WHERE action_message_id = @msgId"
342+
343+
let! _ = conn.ExecuteAsync(sql, {| msgId = actionMessageId |})
344+
return ()
345+
}
346+
335347
/// Gets all callbacks for a user (for cleanup when user is banned via /ban)
336348
let getCallbacksByUserId (userId: int64): Task<DbCallback array> =
337349
task {

0 commit comments

Comments
 (0)