-
-
Notifications
You must be signed in to change notification settings - Fork 83
Add warn auto-ban for repeated admin /warn commands #398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 15 commits
f137fac
ea48593
a8d2739
3203574
6c1783e
b9ebf00
8964290
8ae4ef5
923d98d
3981b0d
4fdd756
07f0eb7
4b44da8
d746808
d6311ab
20a13df
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -16,6 +16,14 @@ import ( | |||||||||||||||||||||||||
| "github.com/umputun/tg-spam/app/bot" | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| //go:generate moq --out mocks/warnings.go --pkg mocks --with-resets --skip-ensure . Warnings | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Warnings is an interface for admin /warn records storage used by the warn auto-ban feature | ||||||||||||||||||||||||||
| type Warnings interface { | ||||||||||||||||||||||||||
| Add(ctx context.Context, userID int64, userName string) error | ||||||||||||||||||||||||||
| CountWithin(ctx context.Context, userID int64, window time.Duration) (int, error) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // admin is a helper to handle all admin-group related stuff, created by listener | ||||||||||||||||||||||||||
| // public methods kept public (on a private struct) to be able to recognize the api | ||||||||||||||||||||||||||
| type admin struct { | ||||||||||||||||||||||||||
|
|
@@ -31,6 +39,9 @@ type admin struct { | |||||||||||||||||||||||||
| warnMsg string | ||||||||||||||||||||||||||
| aggressiveCleanup bool | ||||||||||||||||||||||||||
| aggressiveCleanupLimit int | ||||||||||||||||||||||||||
| warnings Warnings // storage for /warn records, used by DirectWarnReport auto-ban path | ||||||||||||||||||||||||||
| warnThreshold int // auto-ban after N /warn within warnWindow (0 disables auto-ban) | ||||||||||||||||||||||||||
| warnWindow time.Duration // sliding window for counting warns | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const ( | ||||||||||||||||||||||||||
|
|
@@ -343,27 +354,142 @@ func (a *admin) DirectWarnReport(update tbapi.Update) error { | |||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // make a warning message and replay to origMsg.MessageID | ||||||||||||||||||||||||||
| warnTarget := "@" + origMsg.From.UserName | ||||||||||||||||||||||||||
| warnTargetName := "@" + origMsg.From.UserName | ||||||||||||||||||||||||||
| if origMsg.SenderChat != nil && origMsg.SenderChat.ID != 0 && origMsg.SenderChat.ID != a.primChatID { | ||||||||||||||||||||||||||
| chName := a.channelDisplayName(origMsg.SenderChat) | ||||||||||||||||||||||||||
| if origMsg.SenderChat.UserName != "" { | ||||||||||||||||||||||||||
| warnTarget = "@" + chName | ||||||||||||||||||||||||||
| warnTargetName = "@" + chName | ||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||
| warnTarget = chName | ||||||||||||||||||||||||||
| warnTargetName = chName | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| warnMsg := fmt.Sprintf("warning from %s\n\n%s %s", update.Message.From.UserName, | ||||||||||||||||||||||||||
| warnTarget, a.warnMsg) | ||||||||||||||||||||||||||
| warnTargetName, a.warnMsg) | ||||||||||||||||||||||||||
|
Comment on lines
356
to
+367
|
||||||||||||||||||||||||||
| 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)) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if banErr := a.trackWarnAndMaybeBan(origMsg); banErr != nil { | ||||||||||||||||||||||||||
| errs = multierror.Append(errs, banErr) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if err := errs.ErrorOrNil(); err != nil { | ||||||||||||||||||||||||||
| return fmt.Errorf("direct warn report failed: %w", err) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // warnTarget identifies the entity (user or channel) that a warn applies to. | ||||||||||||||||||||||||||
| // channelID is 0 for plain users; for channel posts it equals the SenderChat ID. | ||||||||||||||||||||||||||
| type warnTarget struct { | ||||||||||||||||||||||||||
| userID int64 | ||||||||||||||||||||||||||
| userName string | ||||||||||||||||||||||||||
| channelID int64 | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // resolveWarnTarget extracts the warn target from the original message. | ||||||||||||||||||||||||||
| // returns (target, true) for plain users and channel posts; (target, false) for | ||||||||||||||||||||||||||
| // anonymous admin posts (SenderChat == group itself, From is shared GroupAnonymousBot) | ||||||||||||||||||||||||||
| // and updates with no resolvable identity. | ||||||||||||||||||||||||||
| func (a *admin) resolveWarnTarget(origMsg *tbapi.Message) (warnTarget, bool) { | ||||||||||||||||||||||||||
| if origMsg.SenderChat != nil && origMsg.SenderChat.ID != 0 { | ||||||||||||||||||||||||||
| // anonymous admin posts have SenderChat.ID == primChatID; From identity is the | ||||||||||||||||||||||||||
| // shared GroupAnonymousBot user. tracking warns against either is meaningless | ||||||||||||||||||||||||||
| // (banning the group itself or a shared bot id), so skip entirely. | ||||||||||||||||||||||||||
| if origMsg.SenderChat.ID == a.primChatID { | ||||||||||||||||||||||||||
| return warnTarget{}, false | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return warnTarget{ | ||||||||||||||||||||||||||
| userID: origMsg.SenderChat.ID, | ||||||||||||||||||||||||||
| userName: a.channelDisplayName(origMsg.SenderChat), | ||||||||||||||||||||||||||
| channelID: origMsg.SenderChat.ID, | ||||||||||||||||||||||||||
| }, true | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| if origMsg.From != nil && origMsg.From.ID != 0 { | ||||||||||||||||||||||||||
| return warnTarget{userID: origMsg.From.ID, userName: origMsg.From.UserName}, true | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return warnTarget{}, false | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // trackWarnAndMaybeBan records the warning and triggers an auto-ban when the | ||||||||||||||||||||||||||
| // configured threshold is reached within the sliding window. it is a no-op when | ||||||||||||||||||||||||||
| // the feature is disabled (threshold == 0), warnings storage is unwired, or the | ||||||||||||||||||||||||||
| // target cannot be resolved (anonymous admin posts, missing From/SenderChat). | ||||||||||||||||||||||||||
| // returns nil unless the ban itself fails - storage failures are logged but not propagated | ||||||||||||||||||||||||||
| // because the warning message has already been posted (best-effort). | ||||||||||||||||||||||||||
| func (a *admin) trackWarnAndMaybeBan(origMsg *tbapi.Message) error { | ||||||||||||||||||||||||||
| if a.warnThreshold == 0 || a.warnings == nil { | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
| // the feature is disabled (threshold == 0), warnings storage is unwired, or the | |
| // target cannot be resolved (anonymous admin posts, missing From/SenderChat). | |
| // returns nil unless the ban itself fails - storage failures are logged but not propagated | |
| // because the warning message has already been posted (best-effort). | |
| func (a *admin) trackWarnAndMaybeBan(origMsg *tbapi.Message) error { | |
| if a.warnThreshold == 0 || a.warnings == nil { | |
| // the feature is disabled (threshold <= 0), warnings storage is unwired, or the | |
| // target cannot be resolved (anonymous admin posts, missing From/SenderChat). | |
| // returns nil unless the ban itself fails - storage failures are logged but not propagated | |
| // because the warning message has already been posted (best-effort). | |
| func (a *admin) trackWarnAndMaybeBan(origMsg *tbapi.Message) error { | |
| if a.warnThreshold <= 0 || a.warnings == nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The flag/env-var examples in this README section appear to have stray
=and commas (e.g.--warn.threshold=, [$WARN_THRESHOLD]and--warn.threshold=). This reads like a formatting typo and makes the enablement instructions ambiguous; please format them like the rest of the README (e.g.,--warn.threshold/$WARN_THRESHOLD, and--warn.window/$WARN_WINDOW).