Skip to content

Commit

Permalink
use spam bot as detector for webapi
Browse files Browse the repository at this point in the history
it already loads and monitor all sample files
  • Loading branch information
umputun committed Dec 24, 2023
1 parent dd18eab commit 9a493c6
Show file tree
Hide file tree
Showing 12 changed files with 138 additions and 69 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@ go.work
data/spam-dynamic.txt
data/ham-dynamic.txt
data/approved-users.txt
data/tg-spam.db
tg-spam.db
logs/
48 changes: 48 additions & 0 deletions app/bot/mocks/detector.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 11 additions & 10 deletions app/bot/spam.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import (
// SpamFilter bot checks if a user is a spammer using lib.Detector
// Reloads spam samples, stop words and excluded tokens on file change.
type SpamFilter struct {
director Detector
params SpamConfig
Detector
params SpamConfig
}

// SpamConfig is a full set of parameters for spam bot
Expand Down Expand Up @@ -54,11 +54,12 @@ type Detector interface {
UpdateHam(msg string) error
AddApprovedUsers(ids ...string)
RemoveApprovedUsers(ids ...string)
ApprovedUsers() (res []string)
}

// NewSpamFilter creates new spam filter
func NewSpamFilter(ctx context.Context, detector Detector, params SpamConfig) *SpamFilter {
res := &SpamFilter{director: detector, params: params}
res := &SpamFilter{Detector: detector, params: params}
go func() {
if err := res.watch(ctx, params.WatchDelay); err != nil {
log.Printf("[WARN] samples file watcher failed: %v", err)
Expand All @@ -73,7 +74,7 @@ func (s *SpamFilter) OnMessage(msg Message) (response Response) {
return Response{}
}
displayUsername := DisplayName(msg)
isSpam, checkResults := s.director.Check(msg.Text, strconv.FormatInt(msg.From.ID, 10))
isSpam, checkResults := s.Check(msg.Text, strconv.FormatInt(msg.From.ID, 10))
crs := []string{}
for _, cr := range checkResults {
crs = append(crs, fmt.Sprintf("{name: %s, spam: %v, details: %s}", cr.Name, cr.Spam, cr.Details))
Expand All @@ -97,7 +98,7 @@ func (s *SpamFilter) OnMessage(msg Message) (response Response) {
// UpdateSpam appends a message to the spam samples file and updates the classifier
func (s *SpamFilter) UpdateSpam(msg string) error {
log.Printf("[DEBUG] update spam samples with %q", msg)
if err := s.director.UpdateSpam(msg); err != nil {
if err := s.Detector.UpdateSpam(msg); err != nil {
return fmt.Errorf("can't update spam samples: %w", err)
}
return nil
Expand All @@ -106,7 +107,7 @@ func (s *SpamFilter) UpdateSpam(msg string) error {
// UpdateHam appends a message to the ham samples file and updates the classifier
func (s *SpamFilter) UpdateHam(msg string) error {
log.Printf("[DEBUG] update ham samples with %q", msg)
if err := s.director.UpdateHam(msg); err != nil {
if err := s.Detector.UpdateHam(msg); err != nil {
return fmt.Errorf("can't update ham samples: %w", err)
}
return nil
Expand All @@ -120,7 +121,7 @@ func (s *SpamFilter) AddApprovedUsers(id int64, ids ...int64) {
for i, id := range combinedIDs {
sids[i] = strconv.FormatInt(id, 10)
}
s.director.AddApprovedUsers(sids...)
s.Detector.AddApprovedUsers(sids...)
}

// RemoveApprovedUsers removes users from the list of approved users
Expand All @@ -131,7 +132,7 @@ func (s *SpamFilter) RemoveApprovedUsers(id int64, ids ...int64) {
for i, id := range combinedIDs {
sids[i] = strconv.FormatInt(id, 10)
}
s.director.RemoveApprovedUsers(sids...)
s.Detector.RemoveApprovedUsers(sids...)
}

// watch watches for changes in samples files and reloads them
Expand Down Expand Up @@ -239,13 +240,13 @@ func (s *SpamFilter) ReloadSamples() (err error) {
defer hamDynamicReader.Close()

// reload samples and stop-words. note: we don't need reset as LoadSamples and LoadStopWords clear the state first
lr, err := s.director.LoadSamples(exclReader, []io.Reader{spamReader, spamDynamicReader},
lr, err := s.LoadSamples(exclReader, []io.Reader{spamReader, spamDynamicReader},
[]io.Reader{hamReader, hamDynamicReader})
if err != nil {
return fmt.Errorf("failed to reload samples: %w", err)
}

ls, err := s.director.LoadStopWords(stopWordsReader)
ls, err := s.LoadStopWords(stopWordsReader)
if err != nil {
return fmt.Errorf("failed to reload stop words: %w", err)
}
Expand Down
12 changes: 6 additions & 6 deletions app/bot/spam_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,23 +342,23 @@ func TestAddApprovedUsers(t *testing.T) {

t.Run("add single approved user", func(t *testing.T) {
mockDirector.ResetCalls()
sf := SpamFilter{director: mockDirector}
sf := SpamFilter{Detector: mockDirector}
sf.AddApprovedUsers(1)
require.Equal(t, 1, len(mockDirector.AddApprovedUsersCalls()))
assert.Equal(t, []string{"1"}, mockDirector.AddApprovedUsersCalls()[0].Ids)
})

t.Run("add multiple approved users", func(t *testing.T) {
mockDirector.ResetCalls()
sf := SpamFilter{director: mockDirector}
sf := SpamFilter{Detector: mockDirector}
sf.AddApprovedUsers(1, 2, 3)
require.Equal(t, 1, len(mockDirector.AddApprovedUsersCalls()))
assert.Equal(t, []string{"1", "2", "3"}, mockDirector.AddApprovedUsersCalls()[0].Ids)
})

t.Run("add empty list of approved users", func(t *testing.T) {
mockDirector.ResetCalls()
sf := SpamFilter{director: mockDirector}
sf := SpamFilter{Detector: mockDirector}
sf.AddApprovedUsers(1, 2, 3)
require.Equal(t, 1, len(mockDirector.AddApprovedUsersCalls()))
assert.Equal(t, []string{"1", "2", "3"}, mockDirector.AddApprovedUsersCalls()[0].Ids)
Expand All @@ -370,23 +370,23 @@ func TestRemoveApprovedUsers(t *testing.T) {

t.Run("remove single approved user", func(t *testing.T) {
mockDirector.ResetCalls()
sf := SpamFilter{director: mockDirector}
sf := SpamFilter{Detector: mockDirector}
sf.RemoveApprovedUsers(1)
require.Equal(t, 1, len(mockDirector.RemoveApprovedUsersCalls()))
assert.Equal(t, []string{"1"}, mockDirector.RemoveApprovedUsersCalls()[0].Ids)
})

t.Run("remove multiple approved users", func(t *testing.T) {
mockDirector.ResetCalls()
sf := SpamFilter{director: mockDirector}
sf := SpamFilter{Detector: mockDirector}
sf.RemoveApprovedUsers(1, 2, 3)
require.Equal(t, 1, len(mockDirector.RemoveApprovedUsersCalls()))
assert.Equal(t, []string{"1", "2", "3"}, mockDirector.RemoveApprovedUsersCalls()[0].Ids)
})

t.Run("remove empty list of approved users", func(t *testing.T) {
mockDirector.ResetCalls()
sf := SpamFilter{director: mockDirector}
sf := SpamFilter{Detector: mockDirector}
sf.RemoveApprovedUsers(1, 2, 3)
require.Equal(t, 1, len(mockDirector.RemoveApprovedUsersCalls()))
assert.Equal(t, []string{"1", "2", "3"}, mockDirector.RemoveApprovedUsersCalls()[0].Ids)
Expand Down
49 changes: 31 additions & 18 deletions app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ import (

type options struct {
Telegram struct {
Token string `long:"token" env:"TOKEN" description:"telegram bot token" required:"true"`
Group string `long:"group" env:"GROUP" description:"group name/id" required:"true"`
Token string `long:"token" env:"TOKEN" description:"telegram bot token"`
Group string `long:"group" env:"GROUP" description:"group name/id"`
Timeout time.Duration `long:"timeout" env:"TIMEOUT" default:"30s" description:"http client timeout for telegram" `
IdleDuration time.Duration `long:"idle" env:"IDLE" default:"30s" description:"idle duration"`
PreserveUnbanned bool `long:"preserve-unbanned" env:"PRESERVE_UNBANNED" description:"preserve user after unban"`
Expand Down Expand Up @@ -153,6 +153,10 @@ func execute(ctx context.Context, opts options) error {
log.Print("[WARN] dry mode, no actual bans")
}

if !opts.Server.Enabled && (opts.Telegram.Token == "" || opts.Telegram.Group == "") {
return errors.New("telegram token and group are required")
}

// make samples and dynamic data dirs
if err := os.MkdirAll(opts.Files.SamplesDataPath, 0o700); err != nil {
return fmt.Errorf("can't make samples dir, %w", err)
Expand All @@ -161,13 +165,6 @@ func execute(ctx context.Context, opts options) error {
return fmt.Errorf("can't make dynamic dir, %w", err)
}

// make telegram bot
tbAPI, err := tbapi.NewBotAPI(opts.Telegram.Token)
if err != nil {
return fmt.Errorf("can't make telegram bot, %w", err)
}
tbAPI.Debug = opts.TGDbg

// make detector with all sample files loaded
detector := makeDetector(opts)

Expand All @@ -178,7 +175,7 @@ func execute(ctx context.Context, opts options) error {
}
log.Printf("[DEBUG] data db: %s", dataFile)

// load approved users and start auto-save
// load approved users
approvedUsersStore, auErr := storage.NewApprovedUsers(dataDB)
if auErr != nil {
return fmt.Errorf("can't make approved users store, %w", auErr)
Expand All @@ -194,21 +191,35 @@ func execute(ctx context.Context, opts options) error {
} else {
log.Printf("[DEBUG] approved users from: %s, loaded: %d", dataFile, count)
}
go autoSaveApprovedUsers(ctx, detector, approvedUsersStore, time.Minute*5)

// make spam bot
spamBot, err := makeSpamBot(ctx, opts, detector)
if err != nil {
return fmt.Errorf("can't make spam bot, %w", err)
}

// activate web server if enabled
if opts.Server.Enabled {
// server starts in background goroutine
if srvErr := activateServer(ctx, opts, detector); srvErr != nil {
if srvErr := activateServer(ctx, opts, spamBot); srvErr != nil {
return fmt.Errorf("can't activate web server, %w", srvErr)
}
if opts.Telegram.Token == "" || opts.Telegram.Group == "" {
log.Printf("[WARN] no telegram token and group, web server only mode")
// if no telegram token and group set, just run the server
<-ctx.Done()
return nil
}
}

// make spam bot
spamBot, err := makeSpamBot(ctx, opts, detector)
// make telegram bot
tbAPI, err := tbapi.NewBotAPI(opts.Telegram.Token)
if err != nil {
return fmt.Errorf("can't make spam bot, %w", err)
return fmt.Errorf("can't make telegram bot, %w", err)
}
tbAPI.Debug = opts.TGDbg

go autoSaveApprovedUsers(ctx, detector, approvedUsersStore, time.Minute*5)

// make spam logger
loggerWr, err := makeSpamLogWriter(opts)
Expand Down Expand Up @@ -251,20 +262,20 @@ func execute(ctx context.Context, opts options) error {
return nil
}

func activateServer(ctx context.Context, opts options, detector *lib.Detector) (err error) {
func activateServer(ctx context.Context, opts options, spamFilter *bot.SpamFilter) (err error) {
log.Printf("[INFO] start web server on %s", opts.Server.ListenAddr)
authPassswd := opts.Server.AuthPasswd
if opts.Server.AuthPasswd == "auto" {
authPassswd, err = webapi.GenerateRandomPassword(20)
if err != nil {
return fmt.Errorf("can't generate random password, %w", err)
}
log.Printf("[WARN] basic auth password for user 'tg-spam': %s", authPassswd)
log.Printf("[WARN] generated basic auth password for user tg-spam: %q", authPassswd)
}

srv := webapi.Server{Config: webapi.Config{
ListenAddr: opts.Server.ListenAddr,
Detector: detector,
SpamFilter: spamFilter.Detector,
AuthPasswd: authPassswd,
Version: revision,
Dbg: opts.Dbg,
Expand All @@ -279,6 +290,8 @@ func activateServer(ctx context.Context, opts options, detector *lib.Detector) (
return nil
}

// makeDetector creates spam detector with all checkers and updaters
// it loads samples and dynamic files
func makeDetector(opts options) *lib.Detector {
detectorConfig := lib.Config{
MaxAllowedEmoji: opts.MaxEmoji,
Expand Down
9 changes: 7 additions & 2 deletions app/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,17 +213,21 @@ func Test_makeSpamBot(t *testing.T) {
})
}

func Test_activateServer(t *testing.T) {
func Test_activateServerOnly(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

var opts options
opts.Server.Enabled = true
opts.Server.ListenAddr = ":9988"
opts.Server.AuthPasswd = "auto"
opts.Files.SamplesDataPath = "webapi/testdata"
opts.Files.DynamicDataPath = "webapi/testdata"

done := make(chan struct{})
go func() {
activateServer(ctx, opts, makeDetector(opts))
err := execute(ctx, opts)
assert.NoError(t, err)
close(done)
}()
time.Sleep(time.Millisecond * 100)
Expand All @@ -235,5 +239,6 @@ func Test_activateServer(t *testing.T) {
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, "pong", string(body))
cancel()
<-done
}
Loading

0 comments on commit 9a493c6

Please sign in to comment.