• 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

76.11
/app/events/listener.go
1
// Package events provide event handlers for telegram bot and all the high-level event handlers.
2
// It parses messages, sends them to the spam detector and handles the results. It can also ban users
3
// and send messages to the admin.
4
//
5
// In addition to that, it provides support for admin chat handling allowing to unban users via the web service and
6
// update the list of spam samples.
7
package events
8

9
import (
10
        "context"
11
        "encoding/json"
12
        "fmt"
13
        "log/slog"
14
        "strconv"
15
        "strings"
16
        "sync"
17
        "time"
18

19
        tbapi "github.com/OvyFlash/telegram-bot-api"
20
        "github.com/hashicorp/go-multierror"
21

22
        "github.com/umputun/tg-spam/app/bot"
23
)
24

25
// TelegramListener listens to tg update, forward to bots and send back responses
26
// Not thread safe
27
type TelegramListener struct {
28
        TbAPI                   TbAPI         // telegram bot API
29
        SpamLogger              SpamLogger    // logger to save spam to files and db
30
        Bot                     Bot           // bot to handle messages
31
        Group                   string        // can be int64 or public group username (without "@" prefix)
32
        AdminGroup              string        // can be int64 or public group username (without "@" prefix)
33
        IdleDuration            time.Duration // idle timeout to send "idle" message to bots
34
        SuperUsers              SuperUsers    // list of superusers, can ban and report spam, can't be banned
35
        TestingIDs              []int64       // list of chat IDs to test the bot
36
        StartupMsg              string        // message to send on startup to the primary chat
37
        WarnMsg                 string        // message to send on warning
38
        NoSpamReply             bool          // do not reply on spam messages in the primary chat
39
        SuppressJoinMessage     bool          // delete join message when kick out user
40
        TrainingMode            bool          // do not ban users, just report and train spam detector
41
        SoftBanMode             bool          // do not ban users, but restrict their actions
42
        Locator                 Locator       // message locator to get info about messages
43
        DisableAdminSpamForward bool          // disable forwarding spam reports to admin chat support
44
        Dry                     bool          // dry run, do not ban or send messages
45

46
        adminHandler *admin
47
        chatID       int64
48
        adminChatID  int64
49

50
        msgs struct {
51
                once sync.Once
52
                ch   chan bot.Response
53
        }
54
}
55

56
// Do process all events, blocked call
57
func (l *TelegramListener) Do(ctx context.Context) error {
23✔
58
        slog.Info("start telegram listener for", slog.Any("Group", l.Group))
23✔
59

23✔
60
        if l.TrainingMode {
26✔
61
                slog.Warn("training mode, no bans")
3✔
62
        }
3✔
63

64
        if l.SoftBanMode {
25✔
65
                slog.Warn("soft ban mode, no bans but restrictions")
2✔
66
        }
2✔
67

68
        // get chat ID for the group we are monitoring
69
        var getChatErr error
23✔
70
        if l.chatID, getChatErr = l.getChatID(l.Group); getChatErr != nil {
23✔
71
                return fmt.Errorf("failed to get chat ID for group %q: %w", l.Group, getChatErr)
×
72
        }
×
73

74
        if err := l.updateSupers(); err != nil {
23✔
NEW
75
                slog.Warn("failed to update superusers", slog.Any("error", err))
×
76
        }
×
77

78
        if l.AdminGroup != "" {
32✔
79
                // get chat ID for the admin group
9✔
80
                if l.adminChatID, getChatErr = l.getChatID(l.AdminGroup); getChatErr != nil {
9✔
81
                        return fmt.Errorf("failed to get chat ID for admin group %q: %w", l.AdminGroup, getChatErr)
×
82
                }
×
83
                slog.Info(fmt.Sprintf("admin chat ID: %d", l.adminChatID))
9✔
84
        }
85

86
        l.msgs.once.Do(func() {
40✔
87
                l.msgs.ch = make(chan bot.Response, 100)
17✔
88
                if l.IdleDuration == 0 {
34✔
89
                        l.IdleDuration = 30 * time.Second
17✔
90
                }
17✔
91
        })
92

93
        // send startup message if any set
94
        if l.StartupMsg != "" && !l.TrainingMode && !l.Dry {
27✔
95
                if err := l.sendBotResponse(bot.Response{Send: true, Text: l.StartupMsg}, l.chatID); err != nil {
4✔
NEW
96
                        slog.Warn("failed to send startup message", slog.Any("error", err))
×
97
                }
×
98
        }
99

100
        l.adminHandler = &admin{tbAPI: l.TbAPI, bot: l.Bot, locator: l.Locator, primChatID: l.chatID, adminChatID: l.adminChatID,
23✔
101
                superUsers: l.SuperUsers, trainingMode: l.TrainingMode, softBan: l.SoftBanMode, dry: l.Dry, warnMsg: l.WarnMsg}
23✔
102

23✔
103
        adminForwardStatus := "enabled"
23✔
104
        if l.DisableAdminSpamForward {
23✔
105
                adminForwardStatus = "disabled"
×
106
        }
×
107
        slog.Debug(fmt.Sprintf("admin handler created, spam forwarding %s, %+v", adminForwardStatus, l.adminHandler))
23✔
108
        u := tbapi.NewUpdate(0)
23✔
109
        u.Timeout = 60
23✔
110

23✔
111
        updates := l.TbAPI.GetUpdatesChan(u)
23✔
112

23✔
113
        for {
69✔
114
                select {
46✔
115

116
                case <-ctx.Done():
×
117
                        return ctx.Err()
×
118

119
                case update, ok := <-updates:
46✔
120
                        if !ok {
69✔
121
                                return fmt.Errorf("telegram update chan closed")
23✔
122
                        }
23✔
123

124
                        // handle admin chat messages
125
                        if update.Message != nil && l.isAdminChat(update.Message.Chat.ID, update.Message.From.UserName, update.Message.From.ID) {
24✔
126
                                if l.DisableAdminSpamForward {
1✔
127
                                        continue
×
128
                                }
129
                                if err := l.adminHandler.MsgHandler(update); err != nil {
1✔
NEW
130
                                        msg := fmt.Sprintf("failed to process admin chat message: %v", err)
×
NEW
131
                                        slog.Warn(msg)
×
132
                                        _ = l.sendBotResponse(bot.Response{Send: true, Text: "error: " + err.Error()}, l.adminChatID)
×
133
                                }
×
134
                                continue
1✔
135
                        }
136

137
                        // handle admin chat inline buttons
138
                        if update.CallbackQuery != nil {
29✔
139
                                if err := l.adminHandler.InlineCallbackHandler(update.CallbackQuery); err != nil {
8✔
140
                                        msg := fmt.Sprintf("failed to process callback: %v", err)
1✔
141
                                        slog.Warn(msg)
1✔
142
                                        _ = l.sendBotResponse(bot.Response{Send: true, Text: "error: " + err.Error()}, l.adminChatID)
1✔
143
                                }
1✔
144
                                continue
7✔
145
                        }
146

147
                        if update.Message == nil {
15✔
148
                                continue
×
149
                        }
150

151
                        // save join messages to locator even if SuppressJoinMessage is set to false
152
                        if update.Message.NewChatMembers != nil {
16✔
153
                                err := l.procNewChatMemberMessage(update)
1✔
154
                                if err != nil {
1✔
NEW
155
                                        msg := fmt.Sprintf("failed to process new chat member: %v", err)
×
NEW
156
                                        slog.Warn(msg)
×
UNCOV
157
                                }
×
158
                                continue
1✔
159
                        }
160

161
                        if update.Message.LeftChatMember != nil {
19✔
162
                                if l.SuppressJoinMessage {
9✔
163
                                        err := l.procLeftChatMemberMessage(update)
4✔
164
                                        if err != nil {
5✔
165
                                                msg := fmt.Sprintf("[WARN] failed to process left chat member: %v", err)
1✔
166
                                                slog.Warn(msg)
1✔
167
                                        }
1✔
168

169
                                }
170
                                continue
5✔
171
                        }
172

173
                        // handle spam reports from superusers
174
                        if update.Message.ReplyToMessage != nil && l.SuperUsers.IsSuper(update.Message.From.UserName, update.Message.From.ID) {
12✔
175
                                if strings.EqualFold(update.Message.Text, "/spam") || strings.EqualFold(update.Message.Text, "spam") {
4✔
176
                                        slog.Debug(fmt.Sprintf("superuser %s reported spam", update.Message.From.UserName))
1✔
177
                                        if err := l.adminHandler.DirectSpamReport(update); err != nil {
1✔
NEW
178
                                                msg := fmt.Sprintf("failed to process direct spam report: %v", err)
×
NEW
179
                                                slog.Warn(msg)
×
UNCOV
180
                                        }
×
181
                                        continue
1✔
182
                                }
183
                                if strings.EqualFold(update.Message.Text, "/ban") || strings.EqualFold(update.Message.Text, "ban") {
2✔
NEW
184
                                        slog.Debug(fmt.Sprintf("superuser %s requested ban", update.Message.From.UserName))
×
185
                                        if err := l.adminHandler.DirectBanReport(update); err != nil {
×
NEW
186
                                                msg := fmt.Sprintf("failed to process direct ban request: %v", err)
×
NEW
187
                                                slog.Warn(msg)
×
188
                                        }
×
189
                                        continue
×
190
                                }
191
                                if strings.EqualFold(update.Message.Text, "/warn") || strings.EqualFold(update.Message.Text, "warn") {
3✔
192
                                        slog.Debug(fmt.Sprintf("superuser %s requested warning", update.Message.From.UserName))
1✔
193
                                        if err := l.adminHandler.DirectWarnReport(update); err != nil {
1✔
NEW
194
                                                msg := fmt.Sprintf("failed to process direct warning request: %v", err)
×
NEW
195
                                                slog.Warn(msg)
×
UNCOV
196
                                        }
×
197
                                        continue
1✔
198
                                }
199
                        }
200

201
                        if err := l.procEvents(update); err != nil {
10✔
202
                                slog.Warn(fmt.Sprintf("failed to process update: %v", err))
3✔
203
                                continue
3✔
204
                        }
205

206
                case <-time.After(l.IdleDuration): // hit bots on idle timeout
×
207
                        resp := l.Bot.OnMessage(bot.Message{Text: "idle"}, false)
×
208
                        if err := l.sendBotResponse(resp, l.chatID); err != nil {
×
NEW
209
                                slog.Warn(fmt.Sprintf("failed to respond on idle: %v", err))
×
210
                        }
×
211
                }
212
        }
213
}
214

215
// procNewChatMemberMessage saves new chat member message to locator. It is used to delete the message if the user kicked out
216
func (l *TelegramListener) procNewChatMemberMessage(update tbapi.Update) error {
6✔
217
        fromChat := update.Message.Chat.ID
6✔
218
        // ignore messages from other chats except the one we are monitor and ones from the test list
6✔
219
        if !l.isChatAllowed(fromChat) {
7✔
220
                return nil
1✔
221
        }
1✔
222

223
        if len(update.Message.NewChatMembers) != 1 {
7✔
224
                slog.Debug(fmt.Sprintf("we are expecting only one new chat member, got %d", len(update.Message.NewChatMembers)))
2✔
225
                return nil
2✔
226
        }
2✔
227

228
        errs := new(multierror.Error)
3✔
229

3✔
230
        member := update.Message.NewChatMembers[0]
3✔
231
        msg := fmt.Sprintf("new_%d_%d", fromChat, member.ID)
3✔
232
        if err := l.Locator.AddMessage(msg, fromChat, member.ID, "", update.Message.MessageID); err != nil {
3✔
233
                errs = multierror.Append(errs, fmt.Errorf("failed to add new chat member message to locator: %w", err))
×
234
        }
×
235

236
        return errs.ErrorOrNil()
3✔
237
}
238

239
// procLeftChatMemberMessage deletes the message about new chat member if the user kicked out
240
func (l *TelegramListener) procLeftChatMemberMessage(update tbapi.Update) error {
9✔
241
        fromChat := update.Message.Chat.ID
9✔
242
        // ignore messages from other chats except the one we are monitor and ones from the test list
9✔
243
        if !l.isChatAllowed(fromChat) {
10✔
244
                return nil
1✔
245
        }
1✔
246

247
        if update.Message.From.ID == update.Message.LeftChatMember.ID {
10✔
248
                slog.Debug("left chat member is the same as the message sender, ignored")
2✔
249
                return nil
2✔
250
        }
2✔
251
        msg, found := l.Locator.Message(fmt.Sprintf("new_%d_%d", fromChat, update.Message.LeftChatMember.ID))
6✔
252
        if !found {
8✔
253
                debugMsg := fmt.Sprintf("no new chat member message found for %d in chat %d", update.Message.LeftChatMember.ID, fromChat)
2✔
254
                slog.Debug(debugMsg)
2✔
255
                return nil
2✔
256
        }
2✔
257
        if _, err := l.TbAPI.Request(tbapi.DeleteMessageConfig{
4✔
258
                BaseChatMessage: tbapi.BaseChatMessage{ChatConfig: tbapi.ChatConfig{ChatID: fromChat}, MessageID: msg.MsgID},
4✔
259
        }); err != nil {
6✔
260
                return fmt.Errorf("failed to delete new chat member message %d: %w", msg.MsgID, err)
2✔
261
        }
2✔
262

263
        return nil
2✔
264
}
265

266
func (l *TelegramListener) procEvents(update tbapi.Update) error {
7✔
267
        msgJSON, errJSON := json.Marshal(update.Message)
7✔
268
        if errJSON != nil {
7✔
269
                return fmt.Errorf("failed to marshal update.Message to json: %w", errJSON)
×
270
        }
×
271
        fromChat := update.Message.Chat.ID
7✔
272
        // ignore messages from other chats except the one we are monitor and ones from the test list
7✔
273
        if !l.isChatAllowed(fromChat) {
7✔
274
                return nil
×
275
        }
×
276

277
        slog.Debug(string(msgJSON))
7✔
278
        msg := transform(update.Message)
7✔
279

7✔
280
        // ignore empty messages
7✔
281
        if strings.TrimSpace(msg.Text) == "" && msg.Image == nil {
7✔
282
                return nil
×
283
        }
×
284

285
        slog.Debug(fmt.Sprintf("incoming msg: %+v", strings.ReplaceAll(msg.Text, "\n", " ")))
7✔
286
        slog.Debug(fmt.Sprintf("incoming msg details: %+v", msg))
7✔
287
        if err := l.Locator.AddMessage(msg.Text, fromChat, msg.From.ID, msg.From.Username, msg.ID); err != nil {
7✔
NEW
288
                msg := fmt.Sprintf("failed to add message to locator: %v", err)
×
NEW
289
                slog.Warn(msg)
×
UNCOV
290
        }
×
291
        resp := l.Bot.OnMessage(*msg, false)
7✔
292

7✔
293
        if !resp.Send { // not spam
7✔
294
                return nil
×
295
        }
×
296

297
        // send response to the channel if allowed
298
        if resp.Send && !l.NoSpamReply && !l.TrainingMode {
13✔
299
                if err := l.sendBotResponse(resp, fromChat); err != nil {
6✔
NEW
300
                        slog.Warn(fmt.Sprintf("failed to respond on update: %v", err))
×
301
                }
×
302
        }
303

304
        errs := new(multierror.Error)
7✔
305

7✔
306
        // ban user if requested by bot
7✔
307
        if resp.Send && resp.BanInterval > 0 {
13✔
308
                slog.Debug(fmt.Sprintf("ban initiated for %+v", resp))
6✔
309
                l.SpamLogger.Save(msg, &resp)
6✔
310
                if err := l.Locator.AddSpam(msg.From.ID, resp.CheckResults); err != nil {
6✔
NEW
311
                        slog.Warn(fmt.Sprintf("failed to add spam to locator: %v", err))
×
312
                }
×
313
                banUserStr := l.getBanUsername(resp, update)
6✔
314

6✔
315
                if l.SuperUsers.IsSuper(msg.From.Username, msg.From.ID) {
7✔
316
                        if l.TrainingMode {
1✔
317
                                l.adminHandler.ReportBan(banUserStr, msg)
×
318
                        }
×
319
                        slog.Debug(fmt.Sprintf("superuser %s requested ban, ignored", banUserStr))
1✔
320
                        return nil
1✔
321
                }
322

323
                banReq := banRequest{duration: resp.BanInterval, userID: resp.User.ID, channelID: resp.ChannelID, userName: banUserStr,
5✔
324
                        chatID: fromChat, dry: l.Dry, training: l.TrainingMode, tbAPI: l.TbAPI, restrict: l.SoftBanMode}
5✔
325
                if err := banUserOrChannel(banReq); err != nil {
8✔
326
                        errs = multierror.Append(errs, fmt.Errorf("failed to ban %s: %w", banUserStr, err))
3✔
327
                } else if l.adminChatID != 0 && msg.From.ID != 0 {
5✔
328
                        l.adminHandler.ReportBan(banUserStr, msg)
×
329
                }
×
330
        }
331

332
        // delete message if requested by bot
333
        if resp.DeleteReplyTo && resp.ReplyTo != 0 && !l.Dry && !l.SuperUsers.IsSuper(msg.From.Username, msg.From.ID) && !l.TrainingMode {
7✔
334
                if _, err := l.TbAPI.Request(tbapi.DeleteMessageConfig{BaseChatMessage: tbapi.BaseChatMessage{
1✔
335
                        MessageID:  resp.ReplyTo,
1✔
336
                        ChatConfig: tbapi.ChatConfig{ChatID: l.chatID},
1✔
337
                }}); err != nil {
1✔
338
                        errs = multierror.Append(errs, fmt.Errorf("failed to delete message %d: %w", resp.ReplyTo, err))
×
339
                }
×
340
        }
341

342
        return errs.ErrorOrNil()
6✔
343
}
344

345
func (l *TelegramListener) isChatAllowed(fromChat int64) bool {
25✔
346
        if fromChat == l.chatID {
46✔
347
                return true
21✔
348
        }
21✔
349
        for _, id := range l.TestingIDs {
6✔
350
                if id == fromChat {
3✔
351
                        return true
1✔
352
                }
1✔
353
        }
354
        return false
3✔
355
}
356

357
func (l *TelegramListener) isAdminChat(fromChat int64, from string, fromID int64) bool {
20✔
358
        if fromChat == l.adminChatID {
23✔
359
                slog.Debug(fmt.Sprintf("message in admin chat %d, from %s (%d)", fromChat, from, fromID))
3✔
360
                if !l.SuperUsers.IsSuper(from, fromID) {
4✔
361
                        slog.Debug(fmt.Sprintf("%s (%d) is not superuser in admin chat, ignored", from, fromID))
1✔
362
                        return false
1✔
363
                }
1✔
364
                return true
2✔
365
        }
366
        return false
17✔
367
}
368

369
func (l *TelegramListener) getBanUsername(resp bot.Response, update tbapi.Update) string {
6✔
370
        if resp.ChannelID == 0 {
8✔
371
                return fmt.Sprintf("%v", resp.User)
2✔
372
        }
2✔
373
        botChat := bot.SenderChat{
4✔
374
                ID: resp.ChannelID,
4✔
375
        }
4✔
376
        if update.Message.SenderChat != nil {
7✔
377
                botChat.UserName = update.Message.SenderChat.UserName
3✔
378
        }
3✔
379
        // if botChat.UserName not set, that means the ban comes from superuser and username should be taken from ReplyToMessage
380
        if botChat.UserName == "" && update.Message.ReplyToMessage.SenderChat != nil {
6✔
381
                if update.Message.ReplyToMessage.ForwardOrigin != nil {
2✔
382
                        if update.Message.ReplyToMessage.ForwardOrigin.IsUser() {
×
383
                                botChat.UserName = update.Message.ReplyToMessage.ForwardOrigin.SenderUser.UserName
×
384
                        }
×
385
                        if update.Message.ReplyToMessage.ForwardOrigin.IsHiddenUser() {
×
386
                                botChat.UserName = update.Message.ReplyToMessage.ForwardOrigin.SenderUserName
×
387
                        }
×
388
                }
389
        }
390
        return fmt.Sprintf("%v", botChat)
4✔
391
}
392

393
// sendBotResponse sends bot's answer to tg channel
394
// actionText is a text for the button to unban user, optional
395
func (l *TelegramListener) sendBotResponse(resp bot.Response, chatID int64) error {
11✔
396
        if !resp.Send {
11✔
397
                return nil
×
398
        }
×
399

400
        slog.Debug(fmt.Sprintf("bot response - %+v, reply-to:%d", strings.ReplaceAll(resp.Text, "\n", "\\n"), resp.ReplyTo))
11✔
401
        tbMsg := tbapi.NewMessage(chatID, resp.Text)
11✔
402
        tbMsg.ParseMode = tbapi.ModeMarkdown
11✔
403
        tbMsg.LinkPreviewOptions = tbapi.LinkPreviewOptions{IsDisabled: true}
11✔
404
        tbMsg.ReplyParameters = tbapi.ReplyParameters{MessageID: resp.ReplyTo}
11✔
405

11✔
406
        if err := send(tbMsg, l.TbAPI); err != nil {
11✔
407
                return fmt.Errorf("can't send message to telegram %q: %w", resp.Text, err)
×
408
        }
×
409

410
        return nil
11✔
411
}
412

413
func (l *TelegramListener) getChatID(group string) (int64, error) {
32✔
414
        chatID, err := strconv.ParseInt(group, 10, 64)
32✔
415
        if err == nil {
41✔
416
                return chatID, nil
9✔
417
        }
9✔
418

419
        chat, err := l.TbAPI.GetChat(tbapi.ChatInfoConfig{ChatConfig: tbapi.ChatConfig{SuperGroupUsername: "@" + group}})
23✔
420
        if err != nil {
23✔
421
                return 0, fmt.Errorf("can't get chat for %s: %w", group, err)
×
422
        }
×
423

424
        return chat.ID, nil
23✔
425
}
426

427
// updateSupers updates the list of super-users based on the chat administrators fetched from the Telegram API.
428
// it uses the user ID first, but can match by username if set in the list of super-users.
429
func (l *TelegramListener) updateSupers() error {
30✔
430
        isSuper := func(username string, id int64) bool {
41✔
431
                for _, super := range l.SuperUsers {
20✔
432
                        if super == fmt.Sprintf("%d", id) {
9✔
433
                                return true
×
434
                        }
×
435
                        if username != "" && super == username {
10✔
436
                                return true
1✔
437
                        }
1✔
438
                }
439
                return false
10✔
440
        }
441

442
        admins, err := l.TbAPI.GetChatAdministrators(tbapi.ChatAdministratorsConfig{ChatConfig: tbapi.ChatConfig{ChatID: l.chatID}})
30✔
443
        if err != nil {
31✔
444
                return fmt.Errorf("failed to get chat administrators: %w", err)
1✔
445
        }
1✔
446

447
        for _, admin := range admins {
41✔
448
                if admin.User.UserName == "" && admin.User.ID == 0 {
13✔
449
                        continue
1✔
450
                }
451
                if isSuper(admin.User.UserName, admin.User.ID) {
12✔
452
                        continue // already in the list
1✔
453
                }
454
                l.SuperUsers = append(l.SuperUsers, fmt.Sprintf("%d", admin.User.ID))
10✔
455
        }
456

457
        slog.Info(fmt.Sprintf("added admins, full list of supers: {%s}", strings.Join(l.SuperUsers, ", ")))
29✔
458
        return err
29✔
459
}
460

461
// SuperUsers for moderators. Can be either username or user ID.
462
type SuperUsers []string
463

464
// IsSuper checks if userID or username in the list of superusers
465
// First it treats super as user ID, then as username
466
func (s SuperUsers) IsSuper(userName string, userID int64) bool {
23✔
467
        for _, super := range s {
48✔
468
                if id, err := strconv.ParseInt(super, 10, 64); err == nil {
27✔
469
                        // super is user ID
2✔
470
                        if userID == id {
3✔
471
                                return true
1✔
472
                        }
1✔
473
                        continue
1✔
474
                }
475
                // super is username
476
                if strings.EqualFold(userName, super) || strings.EqualFold("/"+userName, super) {
31✔
477
                        return true
8✔
478
                }
8✔
479
        }
480
        return false
14✔
481
}
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