Skip to content

Commit

Permalink
Add soft ban mode for user restrictions (#74)
Browse files Browse the repository at this point in the history
* Add soft ban mode for user restrictions

The update introduces a soft ban mode that restricts user actions instead of banning them outright. This mode, which can be turned on or off, adds flexibility to how the application handles bans. The new mode has been integrated into the admin and listener components and has been tested extensively to ensure it functions as expected.

* update docs
  • Loading branch information
umputun committed Apr 23, 2024
1 parent 3e534bd commit 0a8cfc0
Show file tree
Hide file tree
Showing 7 changed files with 246 additions and 15 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ Success! The new status is: DISABLED. /help
--paranoid paranoid mode, check all messages [$PARANOID]
--first-messages-count= number of first messages to check (default: 1) [$FIRST_MESSAGES_COUNT]
--training training mode, passive spam detection only [$TRAINING]
--soft-ban soft ban mode, restrict user actions but not ban [$SOFT_BAN]
--dry dry mode, no bans [$DRY]
--dbg debug mode [$DEBUG]
--tg-dbg telegram debug mode [$TG_DEBUG]
Expand Down Expand Up @@ -300,7 +302,8 @@ Help Options:
- `--testing-id` - this is needed to debug things if something unusual is going on. All it does is adding any chat ID to the list of chats bots will listen to. This is useful for debugging purposes only, but should not be used in production.
- `--paranoid` - if set to `true`, the bot will check all the messages for spam, not just the first one. This is useful for testing and training purposes.
- `--first-messages-count` - defines how many messages to check for spam. By default, the bot checks only the first message from a given user. However, in some cases, it is useful to check more than one message. For example, if the observed spam starts with a few non-spam messages, the bot will not be able to detect it. Setting this parameter to a higher value will allow the bot to detect such spam. Note: this parameter is ignored if `--paranoid` mode is enabled.
- `--training` - if set to `true`, the bot will not ban users and delete messages but will learn from them. This is useful for training purposes.
- `--training` - if set, the bot will not ban users and delete messages but will learn from them. This is useful for training purposes.
- `--soft-ban` - if set, the bot will restrict user actions but won't ban. This is useful for chats where the false-positive is hard or costly to recover from. With soft ban, the user won't be removed from the chat but will be restricted in actions. Practically, it means the user won't be able to send messages, but the recovery is easy - just unban the user, and they won't need to rejoin the chat.
- `--disable-admin-spam-forward` - if set to `true`, the bot will not treat messages forwarded to the admin chat as spam.
- `--dry` - if set to `true`, the bot will not ban users and delete messages. This is useful for testing purposes.
- `--dbg` - if set to `true`, the bot will print debug information to the console.
Expand Down
32 changes: 26 additions & 6 deletions app/events/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type admin struct {
primChatID int64
adminChatID int64
trainingMode bool
softBan bool // if true, the user not banned automatically, but only restricted
dry bool
warnMsg string
}
Expand Down Expand Up @@ -414,12 +415,8 @@ func (a *admin) callbackUnbanConfirmed(query *tbapi.CallbackQuery) error {

// unban user if not in training mode (in training mode, the user is not banned automatically)
if !a.trainingMode {
// onlyIfBanned seems to prevent user from being removed from the chat according to this confusing doc:
// https://core.telegram.org/bots/api#unbanchatmember
_, err = a.tbAPI.Request(tbapi.UnbanChatMemberConfig{
ChatMemberConfig: tbapi.ChatMemberConfig{UserID: userID, ChatID: a.primChatID}, OnlyIfBanned: true})
if err != nil {
return fmt.Errorf("failed to unban user %d: %w", userID, err)
if uerr := a.unban(userID); uerr != nil {
return uerr
}
}

Expand All @@ -444,6 +441,29 @@ func (a *admin) callbackUnbanConfirmed(query *tbapi.CallbackQuery) error {
return nil
}

func (a *admin) unban(userID int64) error {
if a.softBan { // soft ban, just drop restrictions
_, err := a.tbAPI.Request(tbapi.RestrictChatMemberConfig{
ChatMemberConfig: tbapi.ChatMemberConfig{UserID: userID, ChatID: a.primChatID},
Permissions: &tbapi.ChatPermissions{CanSendMessages: true, CanSendMediaMessages: true, CanSendOtherMessages: true, CanSendPolls: true},
})
if err != nil {
return fmt.Errorf("failed to drop restrictions for user %d: %w", userID, err)
}
return nil
}

// hard ban, unban the user for real
_, err := a.tbAPI.Request(tbapi.UnbanChatMemberConfig{
ChatMemberConfig: tbapi.ChatMemberConfig{UserID: userID, ChatID: a.primChatID}, OnlyIfBanned: true})
// onlyIfBanned seems to prevent user from being removed from the chat according to this confusing doc:
// https://core.telegram.org/bots/api#unbanchatmember
if err != nil {
return fmt.Errorf("failed to unban user %d: %w", userID, err)
}
return nil
}

// callbackShowInfo handles the callback when user asks for spam detection details for the ban.
// callback data: !userID:msgID
func (a *admin) callbackShowInfo(query *tbapi.CallbackQuery) error {
Expand Down
28 changes: 27 additions & 1 deletion app/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ type banRequest struct {
duration time.Duration

dry bool
training bool
training bool // training mode, do not do the actual ban
restrict bool // restrict instead of ban
}

// The bot must be an administrator in the supergroup for this to work
Expand Down Expand Up @@ -134,6 +135,31 @@ func banUserOrChannel(r banRequest) error {
r.duration = 1 * time.Minute
}

if r.restrict {
resp, err := r.tbAPI.Request(tbapi.RestrictChatMemberConfig{
ChatMemberConfig: tbapi.ChatMemberConfig{
ChatID: r.chatID,
UserID: r.userID,
},
UntilDate: time.Now().Add(r.duration).Unix(),
Permissions: &tbapi.ChatPermissions{
CanSendMessages: false,
CanSendMediaMessages: false,
CanSendOtherMessages: false,
CanChangeInfo: false,
CanInviteUsers: false,
CanPinMessages: false,
},
})
if err != nil {
return err
}
if !resp.Ok {
return fmt.Errorf("response is not Ok: %v", string(resp.Result))
}
return nil
}

if r.channelID != 0 {
resp, err := r.tbAPI.Request(tbapi.BanChatSenderChatConfig{
ChatID: r.chatID,
Expand Down
10 changes: 8 additions & 2 deletions app/events/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type TelegramListener struct {
WarnMsg string // message to send on warning
NoSpamReply bool // do not reply on spam messages in the primary chat
TrainingMode bool // do not ban users, just report and train spam detector
SoftBanMode bool // do not ban users, but restrict their actions
Locator Locator // message locator to get info about messages
DisableAdminSpamForward bool // disable forwarding spam reports to admin chat support
Dry bool // dry run, do not ban or send messages
Expand All @@ -59,6 +60,10 @@ func (l *TelegramListener) Do(ctx context.Context) error {
log.Printf("[WARN] training mode, no bans")
}

if l.SoftBanMode {
log.Printf("[INFO] soft ban mode, no bans but restrictions")
}

// get chat ID for the group we are monitoring
var getChatErr error
if l.chatID, getChatErr = l.getChatID(l.Group); getChatErr != nil {
Expand Down Expand Up @@ -92,7 +97,8 @@ func (l *TelegramListener) Do(ctx context.Context) error {
}

l.adminHandler = &admin{tbAPI: l.TbAPI, bot: l.Bot, locator: l.Locator, primChatID: l.chatID, adminChatID: l.adminChatID,
superUsers: l.SuperUsers, trainingMode: l.TrainingMode, dry: l.Dry, warnMsg: l.WarnMsg}
superUsers: l.SuperUsers, trainingMode: l.TrainingMode, softBan: l.SoftBanMode, dry: l.Dry, warnMsg: l.WarnMsg}

adminForwardStatus := "enabled"
if l.DisableAdminSpamForward {
adminForwardStatus = "disabled"
Expand Down Expand Up @@ -239,7 +245,7 @@ func (l *TelegramListener) procEvents(update tbapi.Update) error {
}

banReq := banRequest{duration: resp.BanInterval, userID: resp.User.ID, channelID: resp.ChannelID,
chatID: fromChat, dry: l.Dry, training: l.TrainingMode, tbAPI: l.TbAPI}
chatID: fromChat, dry: l.Dry, training: l.TrainingMode, tbAPI: l.TbAPI, restrict: l.SoftBanMode}
if err := banUserOrChannel(banReq); err == nil {
log.Printf("[INFO] %s banned by bot for %v", banUserStr, resp.BanInterval)
if l.adminChatID != 0 && msg.From.ID != 0 {
Expand Down
152 changes: 152 additions & 0 deletions app/events/listener_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,82 @@ func TestTelegramListener_DoWithBotBan(t *testing.T) {
})
}

func TestTelegramListener_DoWithBotSoftBan(t *testing.T) {
mockLogger := &mocks.SpamLoggerMock{SaveFunc: func(msg *bot.Message, response *bot.Response) {}}
mockAPI := &mocks.TbAPIMock{
GetChatFunc: func(config tbapi.ChatInfoConfig) (tbapi.Chat, error) {
return tbapi.Chat{ID: 123}, nil
},
SendFunc: func(c tbapi.Chattable) (tbapi.Message, error) {
return tbapi.Message{Text: c.(tbapi.MessageConfig).Text, From: &tbapi.User{UserName: "user"}}, nil
},
RequestFunc: func(c tbapi.Chattable) (*tbapi.APIResponse, error) {
return &tbapi.APIResponse{}, nil
},
GetChatAdministratorsFunc: func(config tbapi.ChatAdministratorsConfig) ([]tbapi.ChatMember, error) {
return nil, nil
},
}
b := &mocks.BotMock{OnMessageFunc: func(msg bot.Message) bot.Response {
t.Logf("on-message: %+v", msg)
if msg.Text == "text 123" && msg.From.Username == "user" {
return bot.Response{Send: true, Text: "bot's answer", BanInterval: 2 * time.Minute,
User: bot.User{Username: "user", ID: 1}, CheckResults: []spamcheck.Response{
{Name: "Check1", Spam: true, Details: "Details 1"}}}
}
if msg.From.Username == "ChannelBot" {
return bot.Response{Send: true, Text: "bot's answer for channel", BanInterval: 2 * time.Minute, User: bot.User{Username: "user", ID: 1}, ChannelID: msg.SenderChat.ID}
}
if msg.From.Username == "admin" {
return bot.Response{Send: true, Text: "bot's answer for admin", BanInterval: 2 * time.Minute, User: bot.User{Username: "user", ID: 1}, ChannelID: msg.ReplyTo.SenderChat.ID}
}
return bot.Response{}
}}

locator, teardown := prepTestLocator(t)
defer teardown()

l := TelegramListener{
SpamLogger: mockLogger,
TbAPI: mockAPI,
Bot: b,
SuperUsers: SuperUsers{"admin"},
Group: "gr",
Locator: locator,
SoftBanMode: true,
}

ctx, cancel := context.WithTimeout(context.Background(), 500*time.Minute)
defer cancel()

updMsg := tbapi.Update{
Message: &tbapi.Message{
Chat: &tbapi.Chat{ID: 123},
Text: "text 123",
From: &tbapi.User{UserName: "user", ID: 123},
Date: int(time.Date(2020, 2, 11, 19, 35, 55, 9, time.UTC).Unix()),
},
}

updChan := make(chan tbapi.Update, 1)
updChan <- updMsg
close(updChan)
mockAPI.GetUpdatesChanFunc = func(config tbapi.UpdateConfig) tbapi.UpdatesChannel { return updChan }

err := l.Do(ctx)
assert.EqualError(t, err, "telegram update chan closed")
assert.Equal(t, 1, len(mockLogger.SaveCalls()))
assert.Equal(t, "text 123", mockLogger.SaveCalls()[0].Msg.Text)
assert.Equal(t, "user", mockLogger.SaveCalls()[0].Msg.From.Username)
assert.Equal(t, 1, len(mockAPI.SendCalls()))
assert.Equal(t, "bot's answer", mockAPI.SendCalls()[0].C.(tbapi.MessageConfig).Text)
assert.Equal(t, 1, len(mockAPI.RequestCalls()))
assert.Equal(t, int64(123), mockAPI.RequestCalls()[0].C.(tbapi.RestrictChatMemberConfig).ChatID)
assert.Equal(t, int64(1), mockAPI.RequestCalls()[0].C.(tbapi.RestrictChatMemberConfig).UserID)
assert.Equal(t, &tbapi.ChatPermissions{CanSendMessages: false, CanSendMediaMessages: false, CanSendOtherMessages: false,
CanSendPolls: false}, mockAPI.RequestCalls()[0].C.(tbapi.RestrictChatMemberConfig).Permissions)
}

func TestTelegramListener_DoWithTraining(t *testing.T) {
mockLogger := &mocks.SpamLoggerMock{SaveFunc: func(msg *bot.Message, response *bot.Response) {}}
mockAPI := &mocks.TbAPIMock{
Expand Down Expand Up @@ -708,6 +784,82 @@ func TestTelegramListener_DoWithAdminUnBan(t *testing.T) {
assert.Equal(t, int64(777), b.AddApprovedUserCalls()[0].ID)
}

func TestTelegramListener_DoWithAdminSoftUnBan(t *testing.T) {
mockLogger := &mocks.SpamLoggerMock{}
mockAPI := &mocks.TbAPIMock{
GetChatFunc: func(config tbapi.ChatInfoConfig) (tbapi.Chat, error) {
return tbapi.Chat{ID: 123}, nil
},
SendFunc: func(c tbapi.Chattable) (tbapi.Message, error) {
if mc, ok := c.(tbapi.MessageConfig); ok {
return tbapi.Message{Text: mc.Text, From: &tbapi.User{UserName: "user"}}, nil
}
return tbapi.Message{}, nil
},
RequestFunc: func(c tbapi.Chattable) (*tbapi.APIResponse, error) {
return &tbapi.APIResponse{}, nil
},
GetChatAdministratorsFunc: func(config tbapi.ChatAdministratorsConfig) ([]tbapi.ChatMember, error) { return nil, nil },
}
b := &mocks.BotMock{
UpdateHamFunc: func(msg string) error {
return nil
},
AddApprovedUserFunc: func(id int64, name string) error { return nil },
}

locator, teardown := prepTestLocator(t)
defer teardown()

l := TelegramListener{
SpamLogger: mockLogger,
TbAPI: mockAPI,
Bot: b,
SuperUsers: SuperUsers{"admin"},
Group: "gr",
Locator: locator,
AdminGroup: "123",
SoftBanMode: true,
}

ctx, cancel := context.WithTimeout(context.Background(), 500*time.Minute)
defer cancel()

updMsg := tbapi.Update{
CallbackQuery: &tbapi.CallbackQuery{
Data: "777:999",
Message: &tbapi.Message{
MessageID: 987654,
Chat: &tbapi.Chat{ID: 123},
Text: "unban user blah\n\nthis was the ham, not spam",
From: &tbapi.User{UserName: "user", ID: 999},
ForwardDate: int(time.Date(2020, 2, 11, 19, 35, 55, 9, time.UTC).Unix()),
},
From: &tbapi.User{UserName: "admin", ID: 1000},
},
}
updChan := make(chan tbapi.Update, 1)
updChan <- updMsg
close(updChan)
mockAPI.GetUpdatesChanFunc = func(config tbapi.UpdateConfig) tbapi.UpdatesChannel { return updChan }

err := l.Do(ctx)
assert.EqualError(t, err, "telegram update chan closed")
require.Equal(t, 1, len(mockAPI.SendCalls()))
assert.Equal(t, 987654, mockAPI.SendCalls()[0].C.(tbapi.EditMessageTextConfig).MessageID)
assert.Contains(t, mockAPI.SendCalls()[0].C.(tbapi.EditMessageTextConfig).Text, "by admin in ")
require.Equal(t, 2, len(mockAPI.RequestCalls()))
assert.Equal(t, "accepted", mockAPI.RequestCalls()[0].C.(tbapi.CallbackConfig).Text)

assert.Equal(t, int64(777), mockAPI.RequestCalls()[1].C.(tbapi.RestrictChatMemberConfig).UserID)
assert.Equal(t, &tbapi.ChatPermissions{CanSendMessages: true, CanSendMediaMessages: true, CanSendOtherMessages: true,
CanSendPolls: true}, mockAPI.RequestCalls()[1].C.(tbapi.RestrictChatMemberConfig).Permissions)
require.Equal(t, 1, len(b.UpdateHamCalls()))
assert.Equal(t, "this was the ham, not spam", b.UpdateHamCalls()[0].Msg)
require.Equal(t, 1, len(b.AddApprovedUserCalls()))
assert.Equal(t, int64(777), b.AddApprovedUserCalls()[0].ID)
}

func TestTelegramListener_DoWithAdminUnBan_Training(t *testing.T) {
mockLogger := &mocks.SpamLoggerMock{}
mockAPI := &mocks.TbAPIMock{
Expand Down
8 changes: 5 additions & 3 deletions app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,11 @@ type options struct {
} `group:"server" namespace:"server" env-namespace:"SERVER"`

Training bool `long:"training" env:"TRAINING" description:"training mode, passive spam detection only"`
Dry bool `long:"dry" env:"DRY" description:"dry mode, no bans"`
Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
TGDbg bool `long:"tg-dbg" env:"TG_DEBUG" description:"telegram debug mode"`
SoftBan bool `long:"soft-ban" env:"SOFT_BAN" description:"soft ban mode, restrict user actions but not ban"`

Dry bool `long:"dry" env:"DRY" description:"dry mode, no bans"`
Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
TGDbg bool `long:"tg-dbg" env:"TG_DEBUG" description:"telegram debug mode"`
}

// file names
Expand Down
Loading

0 comments on commit 0a8cfc0

Please sign in to comment.