• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / tg-spam / 12291044410

12 Dec 2024 06:26AM UTC coverage: 78.772% (-0.9%) from 79.673%
12291044410

Pull #194

github

avonar
mr issues
Pull Request #194: add slog and json log option

117 of 197 new or added lines in 10 files covered. (59.39%)

12 existing lines in 2 files now uncovered.

2579 of 3274 relevant lines covered (78.77%)

53.68 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.51
/app/events/admin.go
1
package events
2

3
import (
4
        "errors"
5
        "fmt"
6
        "log/slog"
7
        "regexp"
8
        "strconv"
9
        "strings"
10
        "time"
11
        "unicode/utf8"
12

13
        tbapi "github.com/OvyFlash/telegram-bot-api"
14
        "github.com/hashicorp/go-multierror"
15

16
        "github.com/umputun/tg-spam/app/bot"
17
)
18

19
// admin is a helper to handle all admin-group related stuff, created by listener
20
// public methods kept public (on a private struct) to be able to recognize the api
21
type admin struct {
22
        tbAPI        TbAPI
23
        bot          Bot
24
        locator      Locator
25
        superUsers   SuperUsers
26
        primChatID   int64
27
        adminChatID  int64
28
        trainingMode bool
29
        softBan      bool // if true, the user not banned automatically, but only restricted
30
        dry          bool
31
        warnMsg      string
32
}
33

34
const (
35
        confirmationPrefix = "?"
36
        banPrefix          = "+"
37
        infoPrefix         = "!"
38
)
39

40
// ReportBan a ban message to admin chat with a button to unban the user
41
func (a *admin) ReportBan(banUserStr string, msg *bot.Message) {
3✔
42
        slog.Debug(fmt.Sprintf("report to admin chat, ban msgsData for %s, group: %d", banUserStr, a.adminChatID))
3✔
43
        text := strings.ReplaceAll(escapeMarkDownV1Text(msg.Text), "\n", " ")
3✔
44
        would := ""
3✔
45
        if a.dry {
4✔
46
                would = "would have "
1✔
47
        }
1✔
48

49
        forwardMsg := fmt.Sprintf("**%spermanently banned [%s](tg://user?id=%d)**\n\n%s\n\n",
3✔
50
                would, escapeMarkDownV1Text(banUserStr), msg.From.ID, text)
3✔
51
        if err := a.sendWithUnbanMarkup(forwardMsg, "change ban", msg.From, msg.ID, a.adminChatID); err != nil {
3✔
NEW
52
                slog.Warn("failed to send admin message", slog.Any("error", err))
×
53
        }
×
54
}
55

56
// MsgHandler handles messages received on admin chat. this is usually forwarded spam failed
57
// to be detected by the bot. we need to update spam filter with this message and ban the user.
58
// the user will be baned even in training mode, but not in the dry mode.
59
func (a *admin) MsgHandler(update tbapi.Update) error {
1✔
60
        shrink := func(inp string, maxLen int) string {
1✔
61
                if utf8.RuneCountInString(inp) <= maxLen {
×
62
                        return inp
×
63
                }
×
64
                return string([]rune(inp)[:maxLen]) + "..."
×
65
        }
66

67
        // try to get the forwarded user ID, this is just for logging
68
        fwdID, username := a.getForwardUsernameAndID(update)
1✔
69

1✔
70
        slog.Debug(fmt.Sprintf("message from admin chat: msg id: %d, update id: %d, from: %s, sender: %q (%d)",
1✔
71
                update.Message.MessageID, update.UpdateID, update.Message.From.UserName,
1✔
72
                username, fwdID))
1✔
73

1✔
74
        if username == "" && update.Message.ForwardOrigin == nil {
1✔
75
                // this is a regular message from admin chat, not the forwarded one, ignore it
×
76
                return nil
×
77
        }
×
78

79
        // this is a forwarded message from super to admin chat, it is an example of missed spam
80
        // we need to update spam filter with this message
81
        msgTxt := update.Message.Text
1✔
82
        if msgTxt == "" { // if no text, try to get it from the transformed message
1✔
83
                m := transform(update.Message)
×
84
                msgTxt = m.Text
×
85
        }
×
86
        slog.Debug(fmt.Sprintf("forwarded message from superuser %q (%d) to admin chat %d: %q",
1✔
87
                update.Message.From.UserName, update.Message.From.ID, a.adminChatID, msgTxt))
1✔
88

1✔
89
        // it would be nice to ban this user right away, but we don't have forwarded user ID here due to tg privacy limitation.
1✔
90
        // it is empty in update.Message. to ban this user, we need to get the match on the message from the locator and ban from there.
1✔
91
        info, ok := a.locator.Message(msgTxt)
1✔
92
        if !ok {
1✔
93
                return fmt.Errorf("not found %q in locator", shrink(msgTxt, 50))
×
94
        }
×
95

96
        slog.Debug(fmt.Sprintf("locator found message %s", info))
1✔
97
        errs := new(multierror.Error)
1✔
98

1✔
99
        // check if the forwarded message will ban a super-user and ignore it
1✔
100
        if info.UserName != "" && a.superUsers.IsSuper(info.UserName, info.UserID) {
1✔
101
                return fmt.Errorf("forwarded message is about super-user %s (%d), ignored", info.UserName, info.UserID)
×
102
        }
×
103

104
        // remove user from the approved list and from storage
105
        if err := a.bot.RemoveApprovedUser(info.UserID); err != nil {
1✔
106
                errs = multierror.Append(errs, fmt.Errorf("failed to remove user %d from approved list: %w", info.UserID, err))
×
107
        }
×
108

109
        // make a message with spam info and send to admin chat
110
        spamInfo := []string{}
1✔
111
        // check only, don't update the storage, as all we care here is to get checks results.
1✔
112
        // without checkOnly flag, it may add approved user to the storage after we removed it above.
1✔
113
        resp := a.bot.OnMessage(bot.Message{Text: update.Message.Text, From: bot.User{ID: info.UserID}}, true)
1✔
114
        spamInfoText := "**can't get spam info**"
1✔
115
        for _, check := range resp.CheckResults {
1✔
116
                spamInfo = append(spamInfo, "- "+escapeMarkDownV1Text(check.String()))
×
117
        }
×
118
        if len(spamInfo) > 0 {
1✔
119
                spamInfoText = strings.Join(spamInfo, "\n")
×
120
        }
×
121
        newMsgText := fmt.Sprintf("**original detection results for %q (%d)**\n\n%s\n\n\n*the user banned and message deleted*",
1✔
122
                escapeMarkDownV1Text(info.UserName), info.UserID, spamInfoText)
1✔
123
        if err := send(tbapi.NewMessage(a.adminChatID, newMsgText), a.tbAPI); err != nil {
1✔
124
                errs = multierror.Append(errs, fmt.Errorf("failed to send spap detection results to admin chat: %w", err))
×
125
        }
×
126

127
        if a.dry {
1✔
128
                return errs.ErrorOrNil()
×
129
        }
×
130

131
        // update spam samples
132
        if err := a.bot.UpdateSpam(msgTxt); err != nil {
1✔
133
                return fmt.Errorf("failed to update spam for %q: %w", msgTxt, err)
×
134
        }
×
135

136
        // delete message
137
        _, err := a.tbAPI.Request(tbapi.DeleteMessageConfig{
1✔
138
                BaseChatMessage: tbapi.BaseChatMessage{
1✔
139
                        MessageID:  info.MsgID,
1✔
140
                        ChatConfig: tbapi.ChatConfig{ChatID: a.primChatID},
1✔
141
                },
1✔
142
        })
1✔
143
        if err != nil {
1✔
144
                errs = multierror.Append(errs, fmt.Errorf("failed to delete message %d: %w", info.MsgID, err))
×
145
        } else {
1✔
146
                slog.Info(fmt.Sprintf("message %d deleted", info.MsgID))
1✔
147
        }
1✔
148

149
        // ban user
150
        banReq := banRequest{duration: bot.PermanentBanDuration, userID: info.UserID, chatID: a.primChatID,
1✔
151
                tbAPI: a.tbAPI, dry: a.dry, training: a.trainingMode, userName: username}
1✔
152

1✔
153
        if err := banUserOrChannel(banReq); err != nil {
1✔
154
                errs = multierror.Append(errs, fmt.Errorf("failed to ban user %d: %w", info.UserID, err))
×
155
        }
×
156

157
        return errs.ErrorOrNil()
1✔
158
}
159

160
// DirectSpamReport handles messages replayed with "/spam" or "spam" by admin
161
func (a *admin) DirectSpamReport(update tbapi.Update) error {
1✔
162
        return a.directReport(update, true)
1✔
163
}
1✔
164

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

171
// DirectWarnReport handles messages replayed with "/warn" or "warn" by admin.
172
// it is removing the original message and posting a warning to the main chat as well as recording the warning th admin chat
173
func (a *admin) DirectWarnReport(update tbapi.Update) error {
1✔
174
        slog.Debug(fmt.Sprintf("direct warn by admin %q: msg id: %d, from: %q (%d)",
1✔
175
                update.Message.From.UserName, update.Message.ReplyToMessage.MessageID,
1✔
176
                update.Message.ReplyToMessage.From.UserName, update.Message.ReplyToMessage.From.ID))
1✔
177
        origMsg := update.Message.ReplyToMessage
1✔
178

1✔
179
        // this is a replayed message, it is an example of something we didn't like and want to issue a warning
1✔
180
        msgTxt := origMsg.Text
1✔
181
        if msgTxt == "" { // if no text, try to get it from the transformed message
1✔
182
                m := transform(origMsg)
×
183
                msgTxt = m.Text
×
184
        }
×
185
        slog.Debug(fmt.Sprintf("reported warn message from superuser %q (%d): %q", update.Message.From.UserName, update.Message.From.ID, msgTxt))
1✔
186
        // check if the reply message will ban a super-user and ignore it
1✔
187
        if origMsg.From.UserName != "" && a.superUsers.IsSuper(origMsg.From.UserName, origMsg.From.ID) {
1✔
188
                return fmt.Errorf("warn message is from super-user %s (%d), ignored", origMsg.From.UserName, origMsg.From.ID)
×
189
        }
×
190
        errs := new(multierror.Error)
1✔
191
        // delete original message
1✔
192
        _, err := a.tbAPI.Request(tbapi.DeleteMessageConfig{BaseChatMessage: tbapi.BaseChatMessage{
1✔
193
                MessageID:  origMsg.MessageID,
1✔
194
                ChatConfig: tbapi.ChatConfig{ChatID: a.primChatID},
1✔
195
        }})
1✔
196
        if err != nil {
1✔
197
                errs = multierror.Append(errs, fmt.Errorf("failed to delete message %d: %w", origMsg.MessageID, err))
×
198
        } else {
1✔
199
                slog.Info(fmt.Sprintf("warn message %d deleted", origMsg.MessageID))
1✔
200
        }
1✔
201

202
        // delete reply message
203
        _, err = a.tbAPI.Request(tbapi.DeleteMessageConfig{BaseChatMessage: tbapi.BaseChatMessage{
1✔
204
                MessageID:  update.Message.MessageID,
1✔
205
                ChatConfig: tbapi.ChatConfig{ChatID: a.primChatID},
1✔
206
        }})
1✔
207
        if err != nil {
1✔
208
                errs = multierror.Append(errs, fmt.Errorf("failed to delete message %d: %w", update.Message.MessageID, err))
×
209
        } else {
1✔
210
                slog.Info(fmt.Sprintf("admin warn reprot message %d deleted", update.Message.MessageID))
1✔
211
        }
1✔
212

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

220
        return errs.ErrorOrNil()
1✔
221
}
222

223
// returns the user ID and username from the tg update if's forwarded message,
224
// or just username in case sender is hidden user
225
func (a *admin) getForwardUsernameAndID(update tbapi.Update) (fwdID int64, username string) {
2✔
226
        if update.Message.ForwardOrigin != nil {
3✔
227
                if update.Message.ForwardOrigin.IsUser() {
1✔
228
                        return update.Message.ForwardOrigin.SenderUser.ID, update.Message.ForwardOrigin.SenderUser.UserName
×
229
                }
×
230
                if update.Message.ForwardOrigin.IsHiddenUser() {
1✔
231
                        return 0, update.Message.ForwardOrigin.SenderUserName
×
232
                }
×
233
        }
234
        return 0, ""
2✔
235
}
236

237
// directReport handles messages replayed with "/spam" or "spam", or "/ban" or "ban" by admin
238
func (a *admin) directReport(update tbapi.Update, updateSamples bool) error {
1✔
239
        slog.Debug(fmt.Sprintf("direct ban by admin %q: msg id: %d, from: %q (%d)",
1✔
240
                update.Message.From.UserName, update.Message.ReplyToMessage.MessageID,
1✔
241
                update.Message.ReplyToMessage.From.UserName, update.Message.ReplyToMessage.From.ID))
1✔
242

1✔
243
        origMsg := update.Message.ReplyToMessage
1✔
244

1✔
245
        // this is a replayed message, it is an example of missed spam
1✔
246
        // we need to update spam filter with this message
1✔
247
        msgTxt := origMsg.Text
1✔
248
        if msgTxt == "" { // if no text, try to get it from the transformed message
1✔
249
                m := transform(origMsg)
×
250
                msgTxt = m.Text
×
251
        }
×
252
        slog.Debug(fmt.Sprintf("reported spam message from superuser %q (%d): %q", update.Message.From.UserName, update.Message.From.ID, msgTxt))
1✔
253

1✔
254
        // check if the reply message will ban a super-user and ignore it
1✔
255
        if origMsg.From.UserName != "" && a.superUsers.IsSuper(origMsg.From.UserName, origMsg.From.ID) {
1✔
256
                return fmt.Errorf("banned message is from super-user %s (%d), ignored", origMsg.From.UserName, origMsg.From.ID)
×
257
        }
×
258

259
        errs := new(multierror.Error)
1✔
260
        // remove user from the approved list and from storage
1✔
261
        if err := a.bot.RemoveApprovedUser(origMsg.From.ID); err != nil {
1✔
262
                // error here is not critical, user may not be in the approved list if we run in paranoid mode or
×
263
                // if not reached the threshold for approval yet
×
NEW
264
                slog.Debug(fmt.Sprintf("can't remove user %d from approved list: %v", origMsg.From.ID, err))
×
265
        }
×
266

267
        // make a message with spam info and send to admin chat
268
        spamInfo := []string{}
1✔
269
        // check only, don't update the storage with the new approved user as all we care here is to get checks results
1✔
270
        resp := a.bot.OnMessage(bot.Message{Text: msgTxt, From: bot.User{ID: origMsg.From.ID}}, true)
1✔
271
        spamInfoText := "**can't get spam info**"
1✔
272
        for _, check := range resp.CheckResults {
1✔
273
                spamInfo = append(spamInfo, "- "+escapeMarkDownV1Text(check.String()))
×
274
        }
×
275
        if len(spamInfo) > 0 {
1✔
276
                spamInfoText = strings.Join(spamInfo, "\n")
×
277
        }
×
278
        newMsgText := fmt.Sprintf("**original detection results for %s (%d)**\n\n%s\n\n%s\n\n\n*the user banned by %q and message deleted*",
1✔
279
                escapeMarkDownV1Text(origMsg.From.UserName), origMsg.From.ID, msgTxt, escapeMarkDownV1Text(spamInfoText),
1✔
280
                escapeMarkDownV1Text(update.Message.From.UserName))
1✔
281
        if err := send(tbapi.NewMessage(a.adminChatID, newMsgText), a.tbAPI); err != nil {
1✔
282
                errs = multierror.Append(errs, fmt.Errorf("failed to send spam detection results to admin chat: %w", err))
×
283
        }
×
284

285
        if a.dry {
1✔
286
                return errs.ErrorOrNil()
×
287
        }
×
288

289
        // update spam samples
290
        if updateSamples {
2✔
291
                if err := a.bot.UpdateSpam(msgTxt); err != nil {
1✔
292
                        return fmt.Errorf("failed to update spam for %q: %w", msgTxt, err)
×
293
                }
×
294
        }
295

296
        // delete original message
297
        _, err := a.tbAPI.Request(tbapi.DeleteMessageConfig{BaseChatMessage: tbapi.BaseChatMessage{
1✔
298
                MessageID:  origMsg.MessageID,
1✔
299
                ChatConfig: tbapi.ChatConfig{ChatID: a.primChatID},
1✔
300
        }})
1✔
301
        if err != nil {
1✔
302
                errs = multierror.Append(errs, fmt.Errorf("failed to delete message %d: %w", origMsg.MessageID, err))
×
303
        } else {
1✔
304
                slog.Info(fmt.Sprintf("spam message %d deleted", origMsg.MessageID))
1✔
305
        }
1✔
306

307
        // delete reply message
308
        _, err = a.tbAPI.Request(tbapi.DeleteMessageConfig{BaseChatMessage: tbapi.BaseChatMessage{
1✔
309
                MessageID:  update.Message.MessageID,
1✔
310
                ChatConfig: tbapi.ChatConfig{ChatID: a.primChatID},
1✔
311
        }})
1✔
312
        if err != nil {
1✔
313
                errs = multierror.Append(errs, fmt.Errorf("failed to delete message %d: %w", update.Message.MessageID, err))
×
314
        } else {
1✔
315
                slog.Info(fmt.Sprintf("admin spam reprot message %d deleted", update.Message.MessageID))
1✔
316
        }
1✔
317

318
        _, username := a.getForwardUsernameAndID(update)
1✔
319

1✔
320
        // ban user
1✔
321
        banReq := banRequest{duration: bot.PermanentBanDuration, userID: origMsg.From.ID, chatID: a.primChatID,
1✔
322
                tbAPI: a.tbAPI, dry: a.dry, training: a.trainingMode, userName: username}
1✔
323

1✔
324
        if err := banUserOrChannel(banReq); err != nil {
1✔
325
                errs = multierror.Append(errs, fmt.Errorf("failed to ban user %d: %w", origMsg.From.ID, err))
×
326
        }
×
327

328
        return errs.ErrorOrNil()
1✔
329
}
330

331
// InlineCallbackHandler handles a callback from Telegram, which is a response to a message with inline keyboard.
332
// The callback contains user info, which is used to unban the user.
333
func (a *admin) InlineCallbackHandler(query *tbapi.CallbackQuery) error {
7✔
334
        callbackData := query.Data
7✔
335
        chatID := query.Message.Chat.ID // this is ID of admin chat
7✔
336
        if chatID != a.adminChatID {    // ignore callbacks from other chats, only admin chat is allowed
7✔
337
                return nil
×
338
        }
×
339

340
        // if callback msgsData starts with "?", we should show a confirmation message
341
        if strings.HasPrefix(callbackData, confirmationPrefix) {
8✔
342
                if err := a.callbackAskBanConfirmation(query); err != nil {
1✔
343
                        return fmt.Errorf("failed to make ban confirmation dialog: %w", err)
×
344
                }
×
345
                slog.Debug(fmt.Sprintf("unban confirmation request sent, chatID: %d, userID: %s, orig: %q",
1✔
346
                        chatID, callbackData[1:], query.Message.Text))
1✔
347
                return nil
1✔
348
        }
349

350
        // if callback msgsData starts with "+", we should not unban the user, but rather clear the keyboard and add to spam samples
351
        if strings.HasPrefix(callbackData, banPrefix) {
8✔
352
                if err := a.callbackBanConfirmed(query); err != nil {
3✔
353
                        return fmt.Errorf("failed confirmation ban: %w", err)
1✔
354
                }
1✔
355
                slog.Debug(fmt.Sprintf("ban confirmed, chatID: %d, userID: %s, orig: %q", chatID, callbackData, query.Message.Text))
1✔
356
                return nil
1✔
357
        }
358

359
        // if callback msgsData starts with "!", we should show a spam info details
360
        if strings.HasPrefix(callbackData, infoPrefix) {
5✔
361
                if err := a.callbackShowInfo(query); err != nil {
1✔
362
                        return fmt.Errorf("failed to show spam info: %w", err)
×
363
                }
×
364
                slog.Debug(fmt.Sprintf("spam info sent, chatID: %d, userID: %s, orig: %q", chatID, callbackData, query.Message.Text))
1✔
365
                return nil
1✔
366
        }
367

368
        // no prefix, callback msgsData here is userID, we should unban the user
369
        slog.Debug(fmt.Sprintf("unban action activated, chatID: %d, userID: %s, orig: %q", chatID, callbackData, query.Message.Text))
3✔
370
        if err := a.callbackUnbanConfirmed(query); err != nil {
3✔
371
                return fmt.Errorf("failed to unban user: %w", err)
×
372
        }
×
373
        slog.Info(fmt.Sprintf("user unbanned, chatID: %d, userID: %s, orig: %q", chatID, callbackData, query.Message.Text))
3✔
374
        return nil
3✔
375
}
376

377
// callbackAskBanConfirmation sends a confirmation message to admin chat with two buttons: "unban" and "keep it banned"
378
// callback data: ?userID:msgID
379
func (a *admin) callbackAskBanConfirmation(query *tbapi.CallbackQuery) error {
1✔
380
        callbackData := query.Data
1✔
381

1✔
382
        keepBanned := "Keep it banned"
1✔
383
        if a.trainingMode {
1✔
384
                keepBanned = "Confirm ban"
×
385
        }
×
386

387
        // replace button with confirmation/rejection buttons
388
        confirmationKeyboard := tbapi.NewInlineKeyboardMarkup(
1✔
389
                tbapi.NewInlineKeyboardRow(
1✔
390
                        tbapi.NewInlineKeyboardButtonData("Unban for real", callbackData[1:]),     // remove "?" prefix
1✔
391
                        tbapi.NewInlineKeyboardButtonData(keepBanned, banPrefix+callbackData[1:]), // set "+" prefix
1✔
392
                ),
1✔
393
        )
1✔
394
        editMsg := tbapi.NewEditMessageReplyMarkup(query.Message.Chat.ID, query.Message.MessageID, confirmationKeyboard)
1✔
395
        if err := send(editMsg, a.tbAPI); err != nil {
1✔
396
                return fmt.Errorf("failed to make confiramtion, chatID:%d, msgID:%d, %w", query.Message.Chat.ID, query.Message.MessageID, err)
×
397
        }
×
398
        return nil
1✔
399
}
400

401
// callbackBanConfirmed handles the callback when user kept banned
402
// it clears the keyboard and updates the message text with confirmation of ban kept in place.
403
// it also updates spam samples with the original message
404
// callback data: +userID:msgID
405
func (a *admin) callbackBanConfirmed(query *tbapi.CallbackQuery) error {
2✔
406
        // clear keyboard and update message text with confirmation
2✔
407
        updText := query.Message.Text + fmt.Sprintf("\n\n_ban confirmed by %s in %v_", query.From.UserName, a.sinceQuery(query))
2✔
408
        editMsg := tbapi.NewEditMessageText(query.Message.Chat.ID, query.Message.MessageID, updText)
2✔
409
        editMsg.ReplyMarkup = &tbapi.InlineKeyboardMarkup{InlineKeyboard: [][]tbapi.InlineKeyboardButton{}}
2✔
410
        if err := send(editMsg, a.tbAPI); err != nil {
2✔
411
                return fmt.Errorf("failed to clear confirmation, chatID:%d, msgID:%d, %w", query.Message.Chat.ID, query.Message.MessageID, err)
×
412
        }
×
413

414
        cleanMsg, err := a.getCleanMessage(query.Message.Text)
2✔
415
        if err != nil {
2✔
416
                return fmt.Errorf("failed to get clean message: %w", err)
×
417
        }
×
418

419
        if err = a.bot.UpdateSpam(cleanMsg); err != nil { // update spam samples
2✔
420
                return fmt.Errorf("failed to update spam for %q: %w", cleanMsg, err)
×
421
        }
×
422

423
        userID, msgID, parseErr := a.parseCallbackData(query.Data)
2✔
424
        if parseErr != nil {
2✔
425
                return fmt.Errorf("failed to parse callback's userID %q: %w", query.Data, parseErr)
×
426
        }
×
427

428
        if a.trainingMode {
3✔
429
                // in training mode, the user is not banned automatically, here we do the real ban & delete the message
1✔
430
                if err = a.deleteAndBan(query, userID, msgID); err != nil {
2✔
431
                        return fmt.Errorf("failed to ban user %d: %w", userID, err)
1✔
432
                }
1✔
433
        }
434

435
        // for  soft ban we need to ba user for real on confirmation
436
        if a.softBan && !a.trainingMode {
1✔
437
                userName, err := a.extractUsername(query.Message.Text) // try to extract username from the message
×
438
                if err != nil {
×
NEW
439
                        slog.Debug(fmt.Sprintf("failed to extract username from %q: %v", query.Message.Text, err))
×
440
                        userName = ""
×
441
                }
×
442
                banReq := banRequest{duration: bot.PermanentBanDuration, userID: userID, chatID: a.primChatID,
×
443
                        tbAPI: a.tbAPI, dry: a.dry, training: a.trainingMode, userName: userName, restrict: false}
×
444
                if err := banUserOrChannel(banReq); err != nil {
×
445
                        return fmt.Errorf("failed to ban user %d: %w", userID, err)
×
446
                }
×
447
        }
448

449
        return nil
1✔
450
}
451

452
// callbackUnbanConfirmed handles the callback when user unbanned.
453
// it clears the keyboard and updates the message text with confirmation of unban.
454
// also it unbans the user, adds it to the approved list and updates ham samples with the original message.
455
// callback data: userID:msgID
456
func (a *admin) callbackUnbanConfirmed(query *tbapi.CallbackQuery) error {
3✔
457
        callbackData := query.Data
3✔
458
        chatID := query.Message.Chat.ID // this is ID of admin chat
3✔
459
        slog.Debug(fmt.Sprintf("unban action activated, chatID: %d, userID: %s", chatID, callbackData))
3✔
460
        // callback msgsData here is userID, we should unban the user
3✔
461
        callbackResponse := tbapi.NewCallback(query.ID, "accepted")
3✔
462
        if _, err := a.tbAPI.Request(callbackResponse); err != nil {
3✔
463
                return fmt.Errorf("failed to send callback response: %w", err)
×
464
        }
×
465

466
        userID, _, err := a.parseCallbackData(callbackData)
3✔
467
        if err != nil {
3✔
468
                return fmt.Errorf("failed to parse callback msgsData %q: %w", callbackData, err)
×
469
        }
×
470

471
        // get the original spam message to update ham samples
472
        cleanMsg, err := a.getCleanMessage(query.Message.Text)
3✔
473
        if err != nil {
3✔
474
                return fmt.Errorf("failed to get clean message: %w", err)
×
475
        }
×
476
        if derr := a.bot.UpdateHam(cleanMsg); derr != nil {
3✔
477
                return fmt.Errorf("failed to update ham for %q: %w", cleanMsg, derr)
×
478
        }
×
479

480
        // unban user if not in training mode (in training mode, the user is not banned automatically)
481
        if !a.trainingMode {
5✔
482
                if uerr := a.unban(userID); uerr != nil {
2✔
483
                        return uerr
×
484
                }
×
485
        }
486

487
        // add user to the approved list
488
        name, err := a.extractUsername(query.Message.Text) // try to extract username from the message
3✔
489
        if err != nil {
6✔
490
                slog.Debug(fmt.Sprintf("failed to extract username from %q: %v", query.Message.Text, err))
3✔
491
                name = ""
3✔
492
        }
3✔
493
        if err := a.bot.AddApprovedUser(userID, name); err != nil { // name is not available here
3✔
494
                return fmt.Errorf("failed to add user %d to approved list: %w", userID, err)
×
495
        }
×
496

497
        // Create the original forwarded message with new indication of "unbanned" and an empty keyboard
498
        updText := query.Message.Text + fmt.Sprintf("\n\n_unbanned by %s in %v_", query.From.UserName, a.sinceQuery(query))
3✔
499

3✔
500
        // add spam info to the message
3✔
501
        if !strings.Contains(query.Message.Text, "spam detection results") && userID != 0 {
6✔
502
                spamInfoText := []string{"\n\n**original detection results**\n"}
3✔
503

3✔
504
                info, found := a.locator.Spam(userID)
3✔
505
                if found {
3✔
506
                        for _, check := range info.Checks {
×
507
                                spamInfoText = append(spamInfoText, "- "+escapeMarkDownV1Text(check.String()))
×
508
                        }
×
509
                }
510

511
                if len(spamInfoText) > 1 {
3✔
512
                        updText += strings.Join(spamInfoText, "\n")
×
513
                }
×
514
        }
515

516
        editMsg := tbapi.NewEditMessageText(chatID, query.Message.MessageID, updText)
3✔
517
        editMsg.ReplyMarkup = &tbapi.InlineKeyboardMarkup{InlineKeyboard: [][]tbapi.InlineKeyboardButton{}}
3✔
518
        if err := send(editMsg, a.tbAPI); err != nil {
3✔
519
                return fmt.Errorf("failed to edit message, chatID:%d, msgID:%d, %w", chatID, query.Message.MessageID, err)
×
520
        }
×
521
        return nil
3✔
522
}
523

524
func (a *admin) unban(userID int64) error {
2✔
525
        if a.softBan { // soft ban, just drop restrictions
3✔
526
                _, err := a.tbAPI.Request(tbapi.RestrictChatMemberConfig{
1✔
527
                        ChatMemberConfig: tbapi.ChatMemberConfig{UserID: userID, ChatConfig: tbapi.ChatConfig{ChatID: a.primChatID}},
1✔
528
                        Permissions: &tbapi.ChatPermissions{
1✔
529
                                CanSendMessages:      true,
1✔
530
                                CanSendAudios:        true,
1✔
531
                                CanSendDocuments:     true,
1✔
532
                                CanSendPhotos:        true,
1✔
533
                                CanSendVideos:        true,
1✔
534
                                CanSendVideoNotes:    true,
1✔
535
                                CanSendVoiceNotes:    true,
1✔
536
                                CanSendOtherMessages: true,
1✔
537
                                CanChangeInfo:        true,
1✔
538
                                CanInviteUsers:       true,
1✔
539
                                CanPinMessages:       true,
1✔
540
                        },
1✔
541
                })
1✔
542
                if err != nil {
1✔
543
                        return fmt.Errorf("failed to drop restrictions for user %d: %w", userID, err)
×
544
                }
×
545
                return nil
1✔
546
        }
547

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

559
// callbackShowInfo handles the callback when user asks for spam detection details for the ban.
560
// callback data: !userID:msgID
561
func (a *admin) callbackShowInfo(query *tbapi.CallbackQuery) error {
1✔
562
        callbackData := query.Data
1✔
563
        spamInfoText := "**can't get spam info**"
1✔
564
        spamInfo := []string{}
1✔
565
        userID, _, err := a.parseCallbackData(callbackData)
1✔
566
        if err != nil {
1✔
567
                spamInfo = append(spamInfo, fmt.Sprintf("**failed to parse userID from %q: %v**", callbackData[1:], err))
×
568
        }
×
569

570
        // collect spam detection details
571
        if userID != 0 {
2✔
572
                info, found := a.locator.Spam(userID)
1✔
573
                if found {
2✔
574
                        for _, check := range info.Checks {
3✔
575
                                spamInfo = append(spamInfo, "- "+escapeMarkDownV1Text(check.String()))
2✔
576
                        }
2✔
577
                }
578
                if len(spamInfo) > 0 {
2✔
579
                        spamInfoText = strings.Join(spamInfo, "\n")
1✔
580
                }
1✔
581
        }
582

583
        updText := query.Message.Text + "\n\n**spam detection results**\n" + spamInfoText
1✔
584
        confirmationKeyboard := [][]tbapi.InlineKeyboardButton{}
1✔
585
        if query.Message.ReplyMarkup != nil && len(query.Message.ReplyMarkup.InlineKeyboard) > 0 {
1✔
586
                confirmationKeyboard = query.Message.ReplyMarkup.InlineKeyboard
×
587
                confirmationKeyboard[0] = confirmationKeyboard[0][:1] // remove second button (info)
×
588
        }
×
589
        editMsg := tbapi.NewEditMessageText(query.Message.Chat.ID, query.Message.MessageID, updText)
1✔
590
        editMsg.ReplyMarkup = &tbapi.InlineKeyboardMarkup{InlineKeyboard: confirmationKeyboard}
1✔
591
        editMsg.ParseMode = tbapi.ModeMarkdown
1✔
592
        if err := send(editMsg, a.tbAPI); err != nil {
1✔
593
                return fmt.Errorf("failed to send spam info, chatID:%d, msgID:%d, %w", query.Message.Chat.ID, query.Message.MessageID, err)
×
594
        }
×
595
        return nil
1✔
596
}
597

598
// deleteAndBan deletes the message and bans the user
599
func (a *admin) deleteAndBan(query *tbapi.CallbackQuery, userID int64, msgID int) error {
1✔
600
        errs := new(multierror.Error)
1✔
601
        userName := a.locator.UserNameByID(userID)
1✔
602
        banReq := banRequest{
1✔
603
                duration: bot.PermanentBanDuration,
1✔
604
                userID:   userID,
1✔
605
                chatID:   a.primChatID,
1✔
606
                tbAPI:    a.tbAPI,
1✔
607
                dry:      a.dry,
1✔
608
                training: false, // reset training flag, ban for real
1✔
609
                userName: userName,
1✔
610
        }
1✔
611

1✔
612
        // check if user is super and don't ban if so
1✔
613
        msgFromSuper := userName != "" && a.superUsers.IsSuper(userName, userID)
1✔
614
        if !msgFromSuper {
2✔
615
                if err := banUserOrChannel(banReq); err != nil {
2✔
616
                        errs = multierror.Append(errs, fmt.Errorf("failed to ban user %d: %w", userID, err))
1✔
617
                }
1✔
618
        }
619

620
        // we allow deleting messages from supers. This can be useful if super is training the bot by adding spam messages
621
        _, err := a.tbAPI.Request(tbapi.DeleteMessageConfig{BaseChatMessage: tbapi.BaseChatMessage{
1✔
622
                MessageID:  msgID,
1✔
623
                ChatConfig: tbapi.ChatConfig{ChatID: a.primChatID},
1✔
624
        }})
1✔
625
        if err != nil {
1✔
626
                return fmt.Errorf("failed to delete message %d: %w", query.Message.MessageID, err)
×
627
        }
×
628

629
        // any errors happened above will be returned
630
        if errs.ErrorOrNil() != nil {
2✔
631
                errMsgs := []string{}
1✔
632
                for _, err := range errs.Errors {
2✔
633
                        errStr := err.Error()
1✔
634
                        errMsgs = append(errMsgs, errStr)
1✔
635
                }
1✔
636
                return errors.New(strings.Join(errMsgs, "\n")) // reformat to be md friendly
1✔
637
        }
638

639
        if msgFromSuper {
×
NEW
640
                slog.Info(fmt.Sprintf("message %d deleted, user %q (%d) is super, not banned", msgID, userName, userID))
×
641
        } else {
×
NEW
642
                slog.Info(fmt.Sprintf("message %d deleted, user %q (%d) banned", msgID, userName, userID))
×
643
        }
×
644
        return nil
×
645
}
646

647
// getCleanMessage returns the original message without spam info and buttons
648
// the messages in admin chat look like this:
649
//
650
//        permanently banned {6762723796 VladimirSokolov24 Владимир Соколов}
651
//
652
//        the original message is here
653
//        another line of the original message
654
//
655
// spam detection results
656
func (a *admin) getCleanMessage(msg string) (string, error) {
12✔
657
        // the original message is from the second line, remove newlines and spaces
12✔
658
        msgLines := strings.Split(msg, "\n")
12✔
659
        if len(msgLines) < 2 {
13✔
660
                return "", fmt.Errorf("unexpected message from callback msgsData: %q", msg)
1✔
661
        }
1✔
662

663
        spamInfoLine := len(msgLines)
11✔
664
        for i, line := range msgLines {
52✔
665
                if strings.HasPrefix(line, "spam detection results") || strings.HasPrefix(line, "**spam detection results**") {
45✔
666
                        spamInfoLine = i
4✔
667
                        break
4✔
668
                }
669
        }
670

671
        // ensure we have at least one line of content
672
        if spamInfoLine <= 2 {
12✔
673
                return "", fmt.Errorf("no original message found in callback msgsData: %q", msg)
1✔
674
        }
1✔
675

676
        cleanMsg := strings.Join(msgLines[2:spamInfoLine], "\n")
10✔
677
        return strings.TrimSpace(cleanMsg), nil
10✔
678
}
679

680
// sendWithUnbanMarkup sends a message to admin chat and adds buttons to ui.
681
// text is message with details and action it for the button label to unban, which is user id prefixed with "?" for confirmation;
682
// the second button is to show info about the spam analysis.
683
func (a *admin) sendWithUnbanMarkup(text, action string, user bot.User, msgID int, chatID int64) error {
3✔
684
        slog.Debug(fmt.Sprintf("action response %q: user %+v, msgID:%d, text: %q", action, user, msgID, strings.ReplaceAll(text, "\n", "\\n")))
3✔
685
        tbMsg := tbapi.NewMessage(chatID, text)
3✔
686
        tbMsg.ParseMode = tbapi.ModeMarkdown
3✔
687
        tbMsg.LinkPreviewOptions = tbapi.LinkPreviewOptions{IsDisabled: true}
3✔
688

3✔
689
        tbMsg.ReplyMarkup = tbapi.NewInlineKeyboardMarkup(
3✔
690
                tbapi.NewInlineKeyboardRow(
3✔
691
                        // ?userID to request confirmation
3✔
692
                        tbapi.NewInlineKeyboardButtonData("⛔︎ "+action, fmt.Sprintf("%s%d:%d", confirmationPrefix, user.ID, msgID)),
3✔
693
                        // !userID to request info
3✔
694
                        tbapi.NewInlineKeyboardButtonData("️⚑ info", fmt.Sprintf("%s%d:%d", infoPrefix, user.ID, msgID)),
3✔
695
                ),
3✔
696
        )
3✔
697

3✔
698
        if _, err := a.tbAPI.Send(tbMsg); err != nil {
3✔
699
                return fmt.Errorf("can't send message to telegram %q: %w", text, err)
×
700
        }
×
701
        return nil
3✔
702
}
703

704
// callbackData is a string with userID and msgID separated by ":"
705
func (a *admin) parseCallbackData(data string) (userID int64, msgID int, err error) {
15✔
706
        if len(data) < 3 {
16✔
707
                return 0, 0, fmt.Errorf("unexpected callback data, too short %q", data)
1✔
708
        }
1✔
709

710
        // remove prefix if present from the parsed data
711
        if data[:1] == confirmationPrefix || data[:1] == banPrefix || data[:1] == infoPrefix {
20✔
712
                data = data[1:]
6✔
713
        }
6✔
714

715
        parts := strings.Split(data, ":")
14✔
716
        if len(parts) != 2 {
15✔
717
                return 0, 0, fmt.Errorf("unexpected callback data, should have both ids %q", data)
1✔
718
        }
1✔
719
        if userID, err = strconv.ParseInt(parts[0], 10, 64); err != nil {
15✔
720
                return 0, 0, fmt.Errorf("failed to parse userID %q: %w", parts[0], err)
2✔
721
        }
2✔
722
        if msgID, err = strconv.Atoi(parts[1]); err != nil {
12✔
723
                return 0, 0, fmt.Errorf("failed to parse msgID %q: %w", parts[1], err)
1✔
724
        }
1✔
725

726
        return userID, msgID, nil
10✔
727
}
728

729
// extractUsername tries to extract the username from a ban message
730
func (a *admin) extractUsername(text string) (string, error) {
6✔
731
        // regex for markdown format: [username](tg://user?id=123456)
6✔
732
        markdownRegex := regexp.MustCompile(`\[(.*?)\]\(tg://user\?id=\d+\)`)
6✔
733
        matches := markdownRegex.FindStringSubmatch(text)
6✔
734
        if len(matches) > 1 {
7✔
735
                return matches[1], nil
1✔
736
        }
1✔
737

738
        // regex for plain format: {200312168 umputun Umputun U}
739
        plainRegex := regexp.MustCompile(`\{\d+ (\S+) .+?\}`)
5✔
740
        matches = plainRegex.FindStringSubmatch(text)
5✔
741
        if len(matches) > 1 {
6✔
742
                return matches[1], nil
1✔
743
        }
1✔
744

745
        return "", errors.New("username not found")
4✔
746
}
747

748
// sinceQuery calculates the time elapsed since the message of the query was sent
749
func (a *admin) sinceQuery(query *tbapi.CallbackQuery) time.Duration {
5✔
750
        res := time.Since(time.Unix(int64(query.Message.Date), 0)).Round(time.Second)
5✔
751
        if res < 0 { // negative duration possible if clock is not in sync with tg times and a message is from the future
5✔
752
                res = 0
×
753
        }
×
754
        return res
5✔
755
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc