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

umputun / tg-spam / 12332433781

14 Dec 2024 07:12PM UTC coverage: 79.667% (+0.007%) from 79.66%
12332433781

push

github

umputun
add a test and extra check

5 of 9 new or added lines in 1 file covered. (55.56%)

1 existing line in 1 file now uncovered.

2582 of 3241 relevant lines covered (79.67%)

55.5 hits per line

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

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

3
import (
4
        "errors"
5
        "fmt"
6
        "log"
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
        log.Printf("[DEBUG] 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✔
52
                log.Printf("[WARN] failed to send admin message, %v", 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
        log.Printf("[DEBUG] 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
        log.Printf("[DEBUG] 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
        log.Printf("[DEBUG] 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
                log.Printf("[INFO] 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
        log.Printf("[DEBUG] 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
        log.Printf("[DEBUG] 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
                log.Printf("[INFO] 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
                log.Printf("[INFO] 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
        log.Printf("[DEBUG] 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
        log.Printf("[DEBUG] 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
×
264
                log.Printf("[DEBUG] 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
                log.Printf("[INFO] 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
                log.Printf("[INFO] 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 {
8✔
334
        callbackData := query.Data
8✔
335
        chatID := query.Message.Chat.ID // this is ID of admin chat
8✔
336
        if chatID != a.adminChatID {    // ignore callbacks from other chats, only admin chat is allowed
8✔
337
                return nil
×
338
        }
×
339

340
        // if callback msgsData starts with "?", we should show a confirmation message
341
        if strings.HasPrefix(callbackData, confirmationPrefix) {
9✔
342
                if err := a.callbackAskBanConfirmation(query); err != nil {
1✔
343
                        return fmt.Errorf("failed to make ban confirmation dialog: %w", err)
×
344
                }
×
345
                log.Printf("[DEBUG] 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) {
9✔
352
                if err := a.callbackBanConfirmed(query); err != nil {
3✔
353
                        return fmt.Errorf("failed confirmation ban: %w", err)
1✔
354
                }
1✔
355
                log.Printf("[DEBUG] 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) {
6✔
361
                if err := a.callbackShowInfo(query); err != nil {
1✔
362
                        return fmt.Errorf("failed to show spam info: %w", err)
×
363
                }
×
364
                log.Printf("[DEBUG] 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
        log.Printf("[DEBUG] unban action activated, chatID: %d, userID: %s, orig: %q", chatID, callbackData, query.Message.Text)
4✔
370
        if err := a.callbackUnbanConfirmed(query); err != nil {
4✔
371
                return fmt.Errorf("failed to unban user: %w", err)
×
372
        }
×
373
        log.Printf("[INFO] user unbanned, chatID: %d, userID: %s, orig: %q", chatID, callbackData, query.Message.Text)
4✔
374

4✔
375
        return nil
4✔
376
}
377

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

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

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

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

415
        if cleanMsg, err := a.getCleanMessage(query.Message.Text); err == nil && cleanMsg != "" {
4✔
416
                if err = a.bot.UpdateSpam(cleanMsg); err != nil { // update spam samples
2✔
417
                        return fmt.Errorf("failed to update spam for %q: %w", cleanMsg, err)
×
418
                }
×
NEW
419
        } else {
×
NEW
420
                // we don't want to fail on this error, as lack of a clean message should not prevent deleteAndBan
×
NEW
421
                // for soft and training modes, we just don't need to update spam samples with empty messages.
×
NEW
422
                log.Printf("[DEBUG] failed to get clean message: %v", err)
×
UNCOV
423
        }
×
424

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

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

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

451
        return nil
1✔
452
}
453

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

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

473
        // get the original spam message to update ham samples
474
        if cleanMsg, cleanErr := a.getCleanMessage(query.Message.Text); cleanErr == nil && cleanMsg != "" {
7✔
475
                // update ham samples if we have a clean message
3✔
476
                if upErr := a.bot.UpdateHam(cleanMsg); upErr != nil {
3✔
477
                        return fmt.Errorf("failed to update ham for %q: %w", cleanMsg, upErr)
×
478
                }
×
479
        } else {
1✔
480
                // we don't want to fail on this error, as lack of a clean message should not prevent unban action
1✔
481
                log.Printf("[DEBUG] failed to get clean message: %v", cleanErr)
1✔
482
        }
1✔
483

484
        // unban user if not in training mode (in training mode, the user is not banned automatically)
485
        if !a.trainingMode {
7✔
486
                if uerr := a.unban(userID); uerr != nil {
3✔
487
                        return uerr
×
488
                }
×
489
        }
490

491
        // add user to the approved list
492
        name, err := a.extractUsername(query.Message.Text) // try to extract username from the message
4✔
493
        if err != nil {
8✔
494
                log.Printf("[DEBUG] failed to extract username from %q: %v", query.Message.Text, err)
4✔
495
                name = ""
4✔
496
        }
4✔
497
        if err := a.bot.AddApprovedUser(userID, name); err != nil { // name is not available here
4✔
498
                return fmt.Errorf("failed to add user %d to approved list: %w", userID, err)
×
499
        }
×
500

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

4✔
504
        // add spam info to the message
4✔
505
        if !strings.Contains(query.Message.Text, "spam detection results") && userID != 0 {
8✔
506
                spamInfoText := []string{"\n\n**original detection results**\n"}
4✔
507

4✔
508
                info, found := a.locator.Spam(userID)
4✔
509
                if found {
4✔
510
                        for _, check := range info.Checks {
×
511
                                spamInfoText = append(spamInfoText, "- "+escapeMarkDownV1Text(check.String()))
×
512
                        }
×
513
                }
514

515
                if len(spamInfoText) > 1 {
4✔
516
                        updText += strings.Join(spamInfoText, "\n")
×
517
                }
×
518
        }
519

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

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

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

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

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

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

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

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

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

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

643
        if msgFromSuper {
×
644
                log.Printf("[INFO] message %d deleted, user %q (%d) is super, not banned", msgID, userName, userID)
×
645
        } else {
×
646
                log.Printf("[INFO] message %d deleted, user %q (%d) banned", msgID, userName, userID)
×
647
        }
×
648
        return nil
×
649
}
650

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

667
        spamInfoLine := len(msgLines)
12✔
668
        for i, line := range msgLines {
56✔
669
                if strings.HasPrefix(line, "spam detection results") || strings.HasPrefix(line, "**spam detection results**") {
48✔
670
                        spamInfoLine = i
4✔
671
                        break
4✔
672
                }
673
        }
674

675
        // ensure we have at least one line of content
676
        if spamInfoLine <= 2 {
13✔
677
                return "", fmt.Errorf("no original message found in callback msgsData: %q", msg)
1✔
678
        }
1✔
679

680
        cleanMsg := strings.Join(msgLines[2:spamInfoLine], "\n")
11✔
681
        return strings.TrimSpace(cleanMsg), nil
11✔
682
}
683

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

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

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

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

714
        // remove prefix if present from the parsed data
715
        if data[:1] == confirmationPrefix || data[:1] == banPrefix || data[:1] == infoPrefix {
21✔
716
                data = data[1:]
6✔
717
        }
6✔
718

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

730
        return userID, msgID, nil
11✔
731
}
732

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

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

749
        return "", errors.New("username not found")
5✔
750
}
751

752
// sinceQuery calculates the time elapsed since the message of the query was sent
753
func (a *admin) sinceQuery(query *tbapi.CallbackQuery) time.Duration {
6✔
754
        res := time.Since(time.Unix(int64(query.Message.Date), 0)).Round(time.Second)
6✔
755
        if res < 0 { // negative duration possible if clock is not in sync with tg times and a message is from the future
6✔
756
                res = 0
×
757
        }
×
758
        return res
6✔
759
}
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