Skip to content

Commit

Permalink
and /ban and /spam commands (#60)
Browse files Browse the repository at this point in the history
* and /ban and /spam commands #59

* add missing test, reformat warn

* make warn msg external, update docs
  • Loading branch information
umputun authored Mar 14, 2024
1 parent 101bde8 commit ec5027a
Show file tree
Hide file tree
Showing 5 changed files with 172 additions and 4 deletions.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ To allow such a feature, `--admin.group=, [$ADMIN_GROUP]` must be specified. Th
![unban-confirmation](https://github.com/umputun/tg-spam/raw/master/site/docs/unban-confirmation.png)
</details>

**admin commands**

* Admins can reply to the spam message with the text `spam` or `/spam` to mark it as spam. This is useful for training purposes as the bot will learn from the spam messages marked by the admin and will be able to detect similar spam in the future.

* Replying to the message with the text `ban` or `/ban` will ban the user who sent the message. This is useful for post-moderation purposes. Essentially this is the same as sending `/spam` but without adding the message to the spam samples file.

* Replying to the message with the text `warn` or `/warn` will remove the original message, and send a warning message to the user who sent the message. This is useful for post-moderation purposes. The warning message is defined by `--message.warn=, [$MESSAGE_WARN]` parameter.


### Updating spam and ham samples dynamically

Expand Down Expand Up @@ -270,6 +278,7 @@ message:
--message.startup= startup message [$MESSAGE_STARTUP]
--message.spam= spam message (default: this is spam) [$MESSAGE_SPAM]
--message.dry= spam dry message (default: this is spam (dry mode)) [$MESSAGE_DRY]
--message.warn= warn message (default: You've violated our rules and this is your first and last warning. Further violations will lead to permanent access denial. Stay compliant or face the consequences!) [$MESSAGE_WARN]
server:
--server.enabled enable web server [$SERVER_ENABLED]
Expand Down Expand Up @@ -312,7 +321,7 @@ After that, the moment admin run into a spam message, he could forward it to the

### Training the bot on a live system safely

In case if such an active training on a live system is not possible, the bot can be trained without banning user and deleting messages automatically. Setting `--training ` parameter will disable banning and deleting messages by bot right away, but the rest of the functionality will be the same. This is useful for testing and training purposes as bot can be trained on false-positive samples, by unbanning them in the admin chat as well as with false-negative samples by forwarding them to the bot. Alternatively, admin can reply to the spam message with the text `spam` or `/soam` to mark it as spam.
In case if such an active training on a live system is not possible, the bot can be trained without banning user and deleting messages automatically. Setting `--training ` parameter will disable banning and deleting messages by bot right away, but the rest of the functionality will be the same. This is useful for testing and training purposes as bot can be trained on false-positive samples, by unbanning them in the admin chat as well as with false-negative samples by forwarding them to the bot. Alternatively, admin can reply to the spam message with the text `spam` or `/spam` to mark it as spam.

In this mode admin can ban users manually by clicking the "confirm ban" button on the message. This allows running the bot as a post-moderation tool and training it on the fly.

Expand Down
61 changes: 59 additions & 2 deletions app/events/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type admin struct {
adminChatID int64
trainingMode bool
dry bool
warnMsg string
}

const (
Expand Down Expand Up @@ -138,6 +139,60 @@ func (a *admin) MsgHandler(update tbapi.Update) error {

// DirectSpamReport handles messages replayed with "/spam" or "spam" by admin
func (a *admin) DirectSpamReport(update tbapi.Update) error {
return a.directReport(update, true)
}

// DirectBanReport handles messages replayed with "/ban" or "ban" by admin. doing all the same as DirectSpamReport
// but without updating spam samples
func (a *admin) DirectBanReport(update tbapi.Update) error {
return a.directReport(update, false)
}

// DirectWarnReport handles messages replayed with "/warn" or "warn" by admin.
// it is removing the original message and posting a warning to the main chat as well as recording the warning th admin chat
func (a *admin) DirectWarnReport(update tbapi.Update) error {
log.Printf("[DEBUG] direct warn by admin %q: msg id: %d, from: %q",
update.Message.From.UserName, update.Message.ReplyToMessage.MessageID, update.Message.ReplyToMessage.From.UserName)
origMsg := update.Message.ReplyToMessage

// this is a replayed message, it is an example of something we didn't like and want to issue a warning
msgTxt := origMsg.Text
if msgTxt == "" { // if no text, try to get it from the transformed message
m := transform(origMsg)
msgTxt = m.Text
}
log.Printf("[DEBUG] reported warn message from superuser %q: %q", update.Message.From.UserName, msgTxt)
// check if the reply message will ban a super-user and ignore it
if origMsg.From.UserName != "" && a.superUsers.IsSuper(origMsg.From.UserName) {
return fmt.Errorf("warn message is from super-user %s (%d), ignored", origMsg.From.UserName, origMsg.From.ID)
}
errs := new(multierror.Error)
// delete original message
if _, err := a.tbAPI.Request(tbapi.DeleteMessageConfig{ChatID: a.primChatID, MessageID: origMsg.MessageID}); err != nil {
errs = multierror.Append(errs, fmt.Errorf("failed to delete message %d: %w", origMsg.MessageID, err))
} else {
log.Printf("[INFO] warn message %d deleted", origMsg.MessageID)
}

// delete reply message
if _, err := a.tbAPI.Request(tbapi.DeleteMessageConfig{ChatID: a.primChatID, MessageID: update.Message.MessageID}); err != nil {
errs = multierror.Append(errs, fmt.Errorf("failed to delete message %d: %w", update.Message.MessageID, err))
} else {
log.Printf("[INFO] admin warn reprot message %d deleted", update.Message.MessageID)
}

// make a warning message and replay to origMsg.MessageID
warnMsg := fmt.Sprintf("warning from %s\n\n@%s %s", update.Message.From.UserName,
origMsg.From.UserName, a.warnMsg)
if err := send(tbapi.NewMessage(a.primChatID, escapeMarkDownV1Text(warnMsg)), a.tbAPI); err != nil {
errs = multierror.Append(errs, fmt.Errorf("failed to send warning to main chat: %w", err))
}

return errs.ErrorOrNil()
}

// directReport handles messages replayed with "/spam" or "spam", or "/ban" or "ban" by admin
func (a *admin) directReport(update tbapi.Update, updateSamples bool) error {
log.Printf("[DEBUG] direct ban by admin %q: msg id: %d, from: %q",
update.Message.From.UserName, update.Message.ReplyToMessage.MessageID, update.Message.ReplyToMessage.From.UserName)

Expand Down Expand Up @@ -187,8 +242,10 @@ func (a *admin) DirectSpamReport(update tbapi.Update) error {
}

// update spam samples
if err := a.bot.UpdateSpam(msgTxt); err != nil {
return fmt.Errorf("failed to update spam for %q: %w", msgTxt, err)
if updateSamples {
if err := a.bot.UpdateSpam(msgTxt); err != nil {
return fmt.Errorf("failed to update spam for %q: %w", msgTxt, err)
}
}

// delete original message
Expand Down
17 changes: 16 additions & 1 deletion app/events/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type TelegramListener struct {
SuperUsers SuperUsers // list of superusers, can ban and report spam, can't be banned
TestingIDs []int64 // list of chat IDs to test the bot
StartupMsg string // message to send on startup to the primary chat
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
Locator Locator // message locator to get info about messages
Expand Down Expand Up @@ -91,7 +92,7 @@ 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}
superUsers: l.SuperUsers, trainingMode: l.TrainingMode, dry: l.Dry, warnMsg: l.WarnMsg}
adminForwardStatus := "enabled"
if l.DisableAdminSpamForward {
adminForwardStatus = "disabled"
Expand Down Expand Up @@ -152,6 +153,20 @@ func (l *TelegramListener) Do(ctx context.Context) error {
}
continue
}
if strings.EqualFold(update.Message.Text, "/ban") || strings.EqualFold(update.Message.Text, "ban") {
log.Printf("[DEBUG] superuser %s requested ban", update.Message.From.UserName)
if err := l.adminHandler.DirectBanReport(update); err != nil {
log.Printf("[WARN] failed to process direct ban request: %v", err)
}
continue
}
if strings.EqualFold(update.Message.Text, "/warn") || strings.EqualFold(update.Message.Text, "warn") {
log.Printf("[DEBUG] superuser %s requested warning", update.Message.From.UserName)
if err := l.adminHandler.DirectWarnReport(update); err != nil {
log.Printf("[WARN] failed to process direct warning request: %v", err)
}
continue
}
}

if err := l.procEvents(update); err != nil {
Expand Down
85 changes: 85 additions & 0 deletions app/events/listener_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,92 @@ func TestTelegramListener_DoWithDirectSpamReport(t *testing.T) {
assert.Equal(t, int(0), mockAPI.RequestCalls()[1].C.(tbapi.DeleteMessageConfig).MessageID)
assert.Equal(t, int64(123), mockAPI.RequestCalls()[2].C.(tbapi.BanChatMemberConfig).ChatID)
assert.Equal(t, int64(666), mockAPI.RequestCalls()[2].C.(tbapi.BanChatMemberConfig).UserID)
}

func TestTelegramListener_DoWithDirectWarnReport(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{Ok: true}, nil
},
GetChatAdministratorsFunc: func(config tbapi.ChatAdministratorsConfig) ([]tbapi.ChatMember, error) { return nil, nil },
}
b := &mocks.BotMock{
RemoveApprovedUserFunc: func(id int64) error {
return nil
},
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"}
}
return bot.Response{}
},
UpdateSpamFunc: func(msg string) error {
t.Logf("update-spam: %s", msg)
return nil
},
}

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

l := TelegramListener{
SpamLogger: mockLogger,
TbAPI: mockAPI,
Bot: b,
Group: "gr",
StartupMsg: "startup",
SuperUsers: SuperUsers{"superuser1"}, // include a test superuser
Locator: locator,
WarnMsg: "You've violated our rules",
}

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

updMsg := tbapi.Update{
Message: &tbapi.Message{
Chat: &tbapi.Chat{ID: 123},
Text: "/wArn",
From: &tbapi.User{UserName: "superuser1", ID: 77},
ReplyToMessage: &tbapi.Message{
MessageID: 999999,
From: &tbapi.User{ID: 666, UserName: "user"},
Text: "text 123",
},
},
}
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, 0, len(mockLogger.SaveCalls()))

require.Equal(t, 2, len(mockAPI.SendCalls()))
assert.Equal(t, "startup", mockAPI.SendCalls()[0].C.(tbapi.MessageConfig).Text)
assert.Contains(t, mockAPI.SendCalls()[1].C.(tbapi.MessageConfig).Text, "warning from superuser1")
assert.Contains(t, mockAPI.SendCalls()[1].C.(tbapi.MessageConfig).Text, `@user You've violated our rules`)

require.Empty(t, b.OnMessageCalls())
require.Empty(t, b.UpdateSpamCalls())
require.Empty(t, b.RemoveApprovedUserCalls())

require.Equal(t, 2, len(mockAPI.RequestCalls()))
assert.Equal(t, int64(123), mockAPI.RequestCalls()[0].C.(tbapi.DeleteMessageConfig).ChatID)
assert.Equal(t, int(999999), mockAPI.RequestCalls()[0].C.(tbapi.DeleteMessageConfig).MessageID)
assert.Equal(t, int64(123), mockAPI.RequestCalls()[1].C.(tbapi.DeleteMessageConfig).ChatID)
assert.Equal(t, int(0), mockAPI.RequestCalls()[1].C.(tbapi.DeleteMessageConfig).MessageID)
}

func TestTelegramListener_DoWithAdminUnBan(t *testing.T) {
Expand Down
2 changes: 2 additions & 0 deletions app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ type options struct {
Startup string `long:"startup" env:"STARTUP" default:"" description:"startup message"`
Spam string `long:"spam" env:"SPAM" default:"this is spam" description:"spam message"`
Dry string `long:"dry" env:"DRY" default:"this is spam (dry mode)" description:"spam dry message"`
Warn string `long:"warn" env:"WARN" default:"You've violated our rules and this is your first and last warning. Further violations will lead to permanent access denial. Stay compliant or face the consequences!" description:"warning message"`
} `group:"message" namespace:"message" env-namespace:"MESSAGE"`

Server struct {
Expand Down Expand Up @@ -261,6 +262,7 @@ func execute(ctx context.Context, opts options) error {
SuperUsers: opts.SuperUsers,
Bot: spamBot,
StartupMsg: opts.Message.Startup,
WarnMsg: opts.Message.Warn,
NoSpamReply: opts.NoSpamReply,
SpamLogger: spamLogger,
AdminGroup: opts.AdminGroup,
Expand Down

0 comments on commit ec5027a

Please sign in to comment.