• 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

82.04
/app/events/events.go
1
package events
2

3
import (
4
        "fmt"
5
        "log/slog"
6
        "strings"
7
        "time"
8

9
        tbapi "github.com/OvyFlash/telegram-bot-api"
10

11
        "github.com/umputun/tg-spam/app/bot"
12
        "github.com/umputun/tg-spam/app/storage"
13
        "github.com/umputun/tg-spam/lib/spamcheck"
14
)
15

16
//go:generate moq --out mocks/tb_api.go --pkg mocks --with-resets --skip-ensure . TbAPI
17
//go:generate moq --out mocks/spam_logger.go --pkg mocks --with-resets --skip-ensure . SpamLogger
18
//go:generate moq --out mocks/bot.go --pkg mocks --with-resets --skip-ensure . Bot
19

20
// TbAPI is an interface for telegram bot API, only subset of methods used
21
type TbAPI interface {
22
        GetUpdatesChan(config tbapi.UpdateConfig) tbapi.UpdatesChannel
23
        Send(c tbapi.Chattable) (tbapi.Message, error)
24
        Request(c tbapi.Chattable) (*tbapi.APIResponse, error)
25
        GetChat(config tbapi.ChatInfoConfig) (tbapi.ChatFullInfo, error)
26
        GetChatAdministrators(config tbapi.ChatAdministratorsConfig) ([]tbapi.ChatMember, error)
27
}
28

29
// SpamLogger is an interface for spam logger
30
type SpamLogger interface {
31
        Save(msg *bot.Message, response *bot.Response)
32
}
33

34
// SpamLoggerFunc is a function that implements SpamLogger interface
35
type SpamLoggerFunc func(msg *bot.Message, response *bot.Response)
36

37
// Save is a function that implements SpamLogger interface
38
func (f SpamLoggerFunc) Save(msg *bot.Message, response *bot.Response) {
×
39
        f(msg, response)
×
40
}
×
41

42
// Locator is an interface for message locator
43
type Locator interface {
44
        AddMessage(msg string, chatID, userID int64, userName string, msgID int) error
45
        AddSpam(userID int64, checks []spamcheck.Response) error
46
        Message(msg string) (storage.MsgMeta, bool)
47
        Spam(userID int64) (storage.SpamData, bool)
48
        MsgHash(msg string) string
49
        UserNameByID(userID int64) string
50
}
51

52
// Bot is an interface for bot events.
53
type Bot interface {
54
        OnMessage(msg bot.Message, checkOnly bool) (response bot.Response)
55
        UpdateSpam(msg string) error
56
        UpdateHam(msg string) error
57
        AddApprovedUser(id int64, name string) error
58
        RemoveApprovedUser(id int64) error
59
        IsApprovedUser(userID int64) bool
60
}
61

62
func escapeMarkDownV1Text(text string) string {
16✔
63
        escSymbols := []string{"_", "*", "`", "["}
16✔
64
        for _, esc := range escSymbols {
80✔
65
                text = strings.ReplaceAll(text, esc, "\\"+esc)
64✔
66
        }
64✔
67
        return text
16✔
68
}
69

70
// send a message to the telegram as markdown first and if failed - as plain text
71
func send(tbMsg tbapi.Chattable, tbAPI TbAPI) error {
23✔
72
        withParseMode := func(tbMsg tbapi.Chattable, parseMode string) tbapi.Chattable {
47✔
73
                switch msg := tbMsg.(type) {
24✔
74
                case tbapi.MessageConfig:
17✔
75
                        msg.ParseMode = parseMode
17✔
76
                        msg.LinkPreviewOptions = tbapi.LinkPreviewOptions{IsDisabled: true}
17✔
77
                        return msg
17✔
78
                case tbapi.EditMessageTextConfig:
6✔
79
                        msg.ParseMode = parseMode
6✔
80
                        msg.LinkPreviewOptions = tbapi.LinkPreviewOptions{IsDisabled: true}
6✔
81
                        return msg
6✔
82
                case tbapi.EditMessageReplyMarkupConfig:
1✔
83
                        return msg
1✔
84
                }
85
                return tbMsg // don't touch other types
×
86
        }
87

88
        msg := withParseMode(tbMsg, tbapi.ModeMarkdown) // try markdown first
23✔
89
        if _, err := tbAPI.Send(msg); err != nil {
24✔
90
                slog.Warn("failed to send message as markdown", slog.Any("error", err))
1✔
91
                msg = withParseMode(tbMsg, "") // try plain text
1✔
92
                if _, err := tbAPI.Send(msg); err != nil {
1✔
93
                        return fmt.Errorf("can't send message to telegram: %w", err)
×
94
                }
×
95
        }
96
        return nil
23✔
97
}
98

99
type banRequest struct {
100
        tbAPI TbAPI
101

102
        userID    int64
103
        channelID int64
104
        chatID    int64
105
        duration  time.Duration
106
        userName  string
107

108
        dry      bool
109
        training bool // training mode, do not do the actual ban
110
        restrict bool // restrict instead of ban
111
}
112

113
// The bot must be an administrator in the supergroup for this to work
114
// and must have the appropriate admin rights.
115
// If channel is provided, it is banned instead of provided user, permanently.
116
func banUserOrChannel(r banRequest) error {
8✔
117
        // From Telegram Bot API documentation:
8✔
118
        // > If user is restricted for more than 366 days or less than 30 seconds from the current time,
8✔
119
        // > they are considered to be restricted forever
8✔
120
        // Because the API query uses unix timestamp rather than "ban duration",
8✔
121
        // you do not want to accidentally get into this 30-second window of a lifetime ban.
8✔
122
        // In practice BanDuration is equal to ten minutes,
8✔
123
        // so this `if` statement is unlikely to be evaluated to true.
8✔
124

8✔
125
        bannedEntity := fmt.Sprintf("user %d", r.userID)
8✔
126
        if r.channelID != 0 {
11✔
127
                bannedEntity = fmt.Sprintf("channel %d", r.channelID)
3✔
128
        }
3✔
129
        if r.dry {
8✔
NEW
130
                slog.Info(
×
NEW
131
                        "dry run:", slog.Any("ban", r.userID), slog.Any("for", r.duration))
×
132
                return nil
×
133
        }
×
134

135
        if r.training {
9✔
136
                slog.Info("training mode:", slog.Any("ban", bannedEntity), slog.Any("for", r.duration))
1✔
137
                return nil
1✔
138
        }
1✔
139

140
        if r.duration < 30*time.Second {
7✔
141
                r.duration = 1 * time.Minute
×
142
        }
×
143

144
        if r.restrict { // soft ban mode
8✔
145
                resp, err := r.tbAPI.Request(tbapi.RestrictChatMemberConfig{
1✔
146
                        ChatMemberConfig: tbapi.ChatMemberConfig{
1✔
147
                                ChatConfig: tbapi.ChatConfig{ChatID: r.chatID},
1✔
148
                                UserID:     r.userID,
1✔
149
                        },
1✔
150
                        UntilDate: time.Now().Add(r.duration).Unix(),
1✔
151
                        Permissions: &tbapi.ChatPermissions{
1✔
152
                                CanSendMessages:      false,
1✔
153
                                CanSendAudios:        false,
1✔
154
                                CanSendDocuments:     false,
1✔
155
                                CanSendPhotos:        false,
1✔
156
                                CanSendVideos:        false,
1✔
157
                                CanSendVideoNotes:    false,
1✔
158
                                CanSendVoiceNotes:    false,
1✔
159
                                CanSendOtherMessages: false,
1✔
160
                                CanChangeInfo:        false,
1✔
161
                                CanInviteUsers:       false,
1✔
162
                                CanPinMessages:       false,
1✔
163
                        },
1✔
164
                })
1✔
165
                if err != nil {
1✔
166
                        return err
×
167
                }
×
168
                if !resp.Ok {
2✔
169
                        return fmt.Errorf("response is not Ok: %v", string(resp.Result))
1✔
170
                }
1✔
NEW
171
                slog.Info(fmt.Sprintf("%s restricted by bot for %v", r.userName, r.duration))
×
172
                return nil
×
173
        }
174

175
        if r.channelID != 0 {
8✔
176
                resp, err := r.tbAPI.Request(tbapi.BanChatSenderChatConfig{
2✔
177
                        ChatConfig:   tbapi.ChatConfig{ChatID: r.chatID},
2✔
178
                        SenderChatID: r.channelID,
2✔
179
                        UntilDate:    int(time.Now().Add(r.duration).Unix()),
2✔
180
                })
2✔
181
                if err != nil {
2✔
182
                        return err
×
183
                }
×
184
                if !resp.Ok {
3✔
185
                        return fmt.Errorf("response is not Ok: %v", string(resp.Result))
1✔
186
                }
1✔
187
                slog.Info(fmt.Sprintf("channel %d banned by bot for %v", r.channelID, r.duration))
1✔
188
                return nil
1✔
189
        }
190

191
        resp, err := r.tbAPI.Request(tbapi.BanChatMemberConfig{
4✔
192
                ChatMemberConfig: tbapi.ChatMemberConfig{
4✔
193
                        ChatConfig: tbapi.ChatConfig{ChatID: r.chatID},
4✔
194
                        UserID:     r.userID,
4✔
195
                },
4✔
196
                UntilDate: time.Now().Add(r.duration).Unix(),
4✔
197
        })
4✔
198
        if err != nil {
4✔
199
                return err
×
200
        }
×
201
        if !resp.Ok {
6✔
202
                return fmt.Errorf("response is not Ok: %v", string(resp.Result))
2✔
203
        }
2✔
204

205
        slog.Info(fmt.Sprintf("%s banned by bot for %v", r.userName, r.duration))
2✔
206
        return nil
2✔
207
}
208

209
func transform(msg *tbapi.Message) *bot.Message {
14✔
210
        transformEntities := func(entities []tbapi.MessageEntity) *[]bot.Entity {
18✔
211
                if len(entities) == 0 {
4✔
212
                        return nil
×
213
                }
×
214

215
                result := make([]bot.Entity, 0, len(entities))
4✔
216
                for _, entity := range entities {
9✔
217
                        e := bot.Entity{
5✔
218
                                Type:   entity.Type,
5✔
219
                                Offset: entity.Offset,
5✔
220
                                Length: entity.Length,
5✔
221
                                URL:    entity.URL,
5✔
222
                        }
5✔
223
                        if entity.User != nil {
6✔
224
                                e.User = &bot.User{
1✔
225
                                        ID:          entity.User.ID,
1✔
226
                                        Username:    entity.User.UserName,
1✔
227
                                        DisplayName: entity.User.FirstName + " " + entity.User.LastName,
1✔
228
                                }
1✔
229
                        }
1✔
230
                        result = append(result, e)
5✔
231
                }
232

233
                return &result
4✔
234
        }
235

236
        message := bot.Message{
14✔
237
                ID:   msg.MessageID,
14✔
238
                Sent: msg.Time(),
14✔
239
                Text: msg.Text,
14✔
240
        }
14✔
241

14✔
242
        message.ChatID = msg.Chat.ID
14✔
243

14✔
244
        if msg.From != nil {
22✔
245
                message.From = bot.User{
8✔
246
                        ID:       msg.From.ID,
8✔
247
                        Username: msg.From.UserName,
8✔
248
                }
8✔
249
        }
8✔
250

251
        if msg.From != nil && strings.TrimSpace(msg.From.FirstName) != "" {
15✔
252
                message.From.DisplayName = msg.From.FirstName
1✔
253
        }
1✔
254
        if msg.From != nil && strings.TrimSpace(msg.From.LastName) != "" {
15✔
255
                message.From.DisplayName += " " + msg.From.LastName
1✔
256
        }
1✔
257

258
        if msg.SenderChat != nil {
18✔
259
                message.SenderChat = bot.SenderChat{
4✔
260
                        ID:       msg.SenderChat.ID,
4✔
261
                        UserName: msg.SenderChat.UserName,
4✔
262
                }
4✔
263
        }
4✔
264

265
        switch {
14✔
266
        case len(msg.Entities) > 0:
3✔
267
                message.Entities = transformEntities(msg.Entities)
3✔
268

269
        case len(msg.Photo) > 0:
1✔
270
                sizes := msg.Photo
1✔
271
                lastSize := sizes[len(sizes)-1]
1✔
272
                message.Image = &bot.Image{
1✔
273
                        FileID:   lastSize.FileID,
1✔
274
                        Width:    lastSize.Width,
1✔
275
                        Height:   lastSize.Height,
1✔
276
                        Caption:  msg.Caption,
1✔
277
                        Entities: transformEntities(msg.CaptionEntities),
1✔
278
                }
1✔
279
        case msg.Video != nil:
×
280
                message.WithVideo = true
×
281
        case msg.VideoNote != nil:
×
282
                message.WithVideoNote = true
×
283
        case msg.Story != nil: // telegram story is a sort of video-like thing, mark it as video
×
284
                message.WithVideo = true
×
285
        }
286

287
        // fill in the message's reply-to message
288
        if msg.ReplyToMessage != nil {
16✔
289
                message.ReplyTo.Text = msg.ReplyToMessage.Text
2✔
290
                message.ReplyTo.Sent = msg.ReplyToMessage.Time()
2✔
291
                if msg.ReplyToMessage.From != nil {
2✔
292
                        message.ReplyTo.From = bot.User{
×
293
                                ID:          msg.ReplyToMessage.From.ID,
×
294
                                Username:    msg.ReplyToMessage.From.UserName,
×
295
                                DisplayName: msg.ReplyToMessage.From.FirstName + " " + msg.ReplyToMessage.From.LastName,
×
296
                        }
×
297
                }
×
298
                if msg.ReplyToMessage.SenderChat != nil {
4✔
299
                        message.ReplyTo.SenderChat = bot.SenderChat{
2✔
300
                                ID:       msg.ReplyToMessage.SenderChat.ID,
2✔
301
                                UserName: msg.ReplyToMessage.SenderChat.UserName,
2✔
302
                        }
2✔
303
                }
2✔
304
        }
305

306
        if msg.Caption != "" {
15✔
307
                if message.Text == "" {
2✔
308
                        slog.Debug("caption only message", slog.Any("caption", msg.Caption))
1✔
309
                        message.Text = msg.Caption
1✔
310
                } else {
1✔
NEW
311
                        slog.Debug("caption appended to message", slog.Any("caption", msg.Caption))
×
312
                        message.Text += "\n" + msg.Caption
×
313
                }
×
314
        }
315
        return &message
14✔
316
}
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