diff --git a/README.md b/README.md index 7949181a..30ba17eb 100644 --- a/README.md +++ b/README.md @@ -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) +**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 @@ -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] @@ -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. diff --git a/app/events/admin.go b/app/events/admin.go index 3cc1f0f2..3a66fffc 100644 --- a/app/events/admin.go +++ b/app/events/admin.go @@ -27,6 +27,7 @@ type admin struct { adminChatID int64 trainingMode bool dry bool + warnMsg string } const ( @@ -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) @@ -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 diff --git a/app/events/listener.go b/app/events/listener.go index 33df5040..89b0e147 100644 --- a/app/events/listener.go +++ b/app/events/listener.go @@ -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 @@ -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" @@ -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 { diff --git a/app/events/listener_test.go b/app/events/listener_test.go index 183308ff..2668eae6 100644 --- a/app/events/listener_test.go +++ b/app/events/listener_test.go @@ -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) { diff --git a/app/main.go b/app/main.go index 7e2c11e7..980f5c76 100644 --- a/app/main.go +++ b/app/main.go @@ -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 { @@ -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,